diff --git a/.gitignore b/.gitignore index 4955ea4..17db4e1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ __pycache__/ # Unit test / coverage reports .coverage +.tox/ +dist/ diff --git a/pyproject.toml b/pyproject.toml index b7153d2..8d8eb47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "mdformat>=0.7.16", "more-itertools", "typer[all]", + "typing-extensions" ] dynamic = ["version"] diff --git a/src/catleg/catleg.py b/src/catleg/catleg.py index 8d9b846..d4ebf60 100644 --- a/src/catleg/catleg.py +++ b/src/catleg/catleg.py @@ -4,6 +4,7 @@ import typer +from catleg.check_expiry import check_expiry as expiry from catleg.cli_util import set_basic_loglevel from catleg.find_changes import find_changes from catleg.query import get_backend @@ -26,6 +27,16 @@ def diff(file: Path): asyncio.run(find_changes(f, file_path=file)) +@app.command() +def check_expiry(file: Path): + """ + Check articles in a catala file for expiry. + """ + with open(file) as f: + retcode = asyncio.run(expiry(f, file_path=file)) + raise typer.Exit(retcode) + + @app.command() def query(article_id: str): """ diff --git a/src/catleg/check_expiry.py b/src/catleg/check_expiry.py new file mode 100644 index 0000000..6f5b8bc --- /dev/null +++ b/src/catleg/check_expiry.py @@ -0,0 +1,49 @@ +import logging +import warnings +from datetime import datetime, timezone +from pathlib import Path +from typing import TextIO + +from catleg.parse_catala_markdown import parse_catala_file +from catleg.query import get_backend + + +logger = logging.getLogger(__name__) + + +async def check_expiry(f: TextIO, *, file_path: Path | None = None): + # parse articles from file + articles = parse_catala_file(f, file_path=file_path) + + back = get_backend("legifrance") + ref_articles = await back.articles([article.id.upper() for article in articles]) + has_expired_articles = False + now = datetime.now(timezone.utc) + + for article, ref_article in zip(articles, ref_articles): + if ref_article is None: + warnings.warn(f"Could not retrieve article '{article.id}'") + continue + + if article.is_archive: + logger.info("article '%s' is achived, skipping expiry check", article.id) + continue + + logger.info("checking article '%s'", article.id) + + if not ref_article.is_open_ended: + if now > ref_article.end_date: + warnings.warn( + f"Article '{article.id}' has expired " + f"(on {ref_article.end_date.date()}). " + f"It has been replaced by '{ref_article.latest_version_id}'." + ) + has_expired_articles = True + else: + warnings.warn( + f"Article '{article.id}' will expire " + f"on {ref_article.end_date.date()}. " + f"It will be replaced by '{ref_article.latest_version_id}'" + ) + + return 0 if not has_expired_articles else 1 diff --git a/src/catleg/law_text_fr.py b/src/catleg/law_text_fr.py index 09cd749..4eb1026 100644 --- a/src/catleg/law_text_fr.py +++ b/src/catleg/law_text_fr.py @@ -54,6 +54,7 @@ class CatalaFileArticle: text: str file_path: Path | None start_line: int + is_archive: bool def parse_article_id(article_id: str) -> tuple[ArticleType, str]: diff --git a/src/catleg/parse_catala_markdown.py b/src/catleg/parse_catala_markdown.py index 577f1be..ed54fae 100644 --- a/src/catleg/parse_catala_markdown.py +++ b/src/catleg/parse_catala_markdown.py @@ -45,6 +45,7 @@ def _parse_catala_doc( typ, id = type_id text = [] curr_elem = window[0] + is_archive = "[archive]" in window[-1].content # start line of the first text block inline_map = window[1].map @@ -72,6 +73,7 @@ def _parse_catala_doc( text=" ".join(text), start_line=start_line, file_path=file_path, + is_archive=is_archive, ) ) diff --git a/src/catleg/query.py b/src/catleg/query.py index e735e06..cddc2fd 100644 --- a/src/catleg/query.py +++ b/src/catleg/query.py @@ -5,21 +5,30 @@ - legistix database (auto-api via datasette) """ -import datetime import functools import logging from collections.abc import Iterable +from datetime import date, datetime, timedelta, timezone from typing import Protocol import aiometer import httpx from markdownify import markdownify as md # type: ignore +from typing_extensions import assert_never from catleg.config import settings from catleg.law_text_fr import Article, ArticleType, parse_article_id +def _lf_timestamp_to_datetime(ts): + return datetime.fromtimestamp(ts / 1000, timezone.utc) + + +# Legifrance uses 2999-01-01 to mark a non-expired or non-expiring text +END_OF_TIME = _lf_timestamp_to_datetime(32472144000000) + + class Backend(Protocol): async def article(self, id: str) -> Article | None: """ @@ -87,7 +96,7 @@ async def _list_codes(self, page_size=20): return results, nb_results async def code_toc(self, id: str): - params = {"textId": id, "date": str(datetime.date.today())} + params = {"textId": id, "date": str(date.today())} reply = await self.client.post( f"{self.API_BASE_URL}/consult/legi/tableMatieres", json=params ) @@ -124,12 +133,35 @@ def get_backend(spec: str): class LegifranceArticle(Article): - def __init__(self, id: str, text: str, text_html: str, nota: str, nota_html: str): + def __init__( + self, + id: str, + text: str, + text_html: str, + nota: str, + nota_html: str, + end_date: int | str | None, + latest_version_id: str, + ): self._id: str = id self._text: str = text self._text_html: str = text_html self._nota: str = nota self._nota_html: str = nota_html + self._end_date: datetime = ( + _lf_timestamp_to_datetime(int(end_date)) + if end_date is not None + else END_OF_TIME + ) + self._latest_version_id = latest_version_id + + @property + def end_date(self) -> datetime: + return self._end_date + + @property + def is_open_ended(self) -> bool: + return self._end_date == END_OF_TIME @property def id(self) -> str: @@ -151,6 +183,10 @@ def nota(self) -> str: def nota_html(self) -> str: return self._nota_html + @property + def latest_version_id(self) -> str: + return self._latest_version_id + def text_and_nota(self) -> str: if len(self.nota): return f"{self._text}\n\nNOTA :\n\n{self._nota}" @@ -192,12 +228,26 @@ def _article_from_legifrance_reply(reply) -> Article | None: else: raise ValueError("Could not parse Legifrance reply") + article_type, article_id = parse_article_id(article["id"]) + match article_type: + case ArticleType.CETATEXT: + latest_version_id = article_id + case ArticleType.LEGIARTI | ArticleType.JORFARTI: + article_versions = sorted( + article["articleVersions"], key=lambda d: int(d["dateDebut"]) + ) + latest_version_id = article_versions[-1]["id"] + case _: + assert_never() + return LegifranceArticle( id=article["id"], text=article["texte"], text_html=article["texteHtml"], nota=article["nota"] or "", nota_html=article["notaHtml"] or "", + end_date=article["dateFin"], + latest_version_id=latest_version_id, ) @@ -217,10 +267,10 @@ def __init__(self, client_id: str, client_secret: str): self.client_id = client_id self.client_secret = client_secret self.token = None - self.token_expires_at: datetime.datetime | None = None + self.token_expires_at: datetime | None = None def auth_flow(self, request: httpx.Request): - if self.token is None or self.token_expires_at <= datetime.datetime.now(): + if self.token is None or self.token_expires_at <= datetime.now(): logging.info("Requesting auth token") data = { "grant_type": "client_credentials", @@ -234,9 +284,7 @@ def auth_flow(self, request: httpx.Request): resp_json = resp.json() self.token = resp_json["access_token"] expires_in = int(resp_json["expires_in"]) - self.token_expires_at = datetime.datetime.now() + datetime.timedelta( - seconds=expires_in - ) + self.token_expires_at = datetime.now() + timedelta(seconds=expires_in) else: logging.info("Using existing auth token") diff --git a/tests/CETATEXT000035260342.json b/tests/CETATEXT000035260342.json new file mode 100644 index 0000000..c73b4e4 --- /dev/null +++ b/tests/CETATEXT000035260342.json @@ -0,0 +1,133 @@ +{ + "executionTime": 12, + "dereferenced": false, + "text": { + "id": "CETATEXT000035260342", + "idEli": null, + "idEliAlias": null, + "origine": "CETAT", + "nature": "Texte", + "cid": null, + "num": "398563", + "numeroBo": null, + "numParution": null, + "juridiction": "Conseil d'État", + "natureJuridiction": "CONSEIL_ETAT", + "solution": null, + "numeroAffaire": [], + "numsequence": null, + "nor": null, + "natureQualifiee": null, + "natureDelib": null, + "datePubli": null, + "datePubliComputed": null, + "dateTexte": 1500595200000, + "dateTexteComputed": 1500595200000, + "dateDerniereModif": null, + "originePubli": null, + "publicationRecueil": "B", + "formation": "5ème - 4ème chambres réunies", + "provenance": null, + "decisionAttaquee": null, + "siegeAppel": null, + "president": null, + "avocatGl": null, + "avocats": null, + "rapporteur": "M. Alain Seban", + "commissaire": null, + "ecli": "ECLI:FR:CECHR:2017:398563.20170721", + "version": null, + "titre": "Conseil d'État, 5ème - 4ème chambres réunies, 21/07/2017, 398563", + "titreLong": "Conseil d'État, 5ème - 4ème chambres réunies, 21/07/2017, 398563", + "titreJo": null, + "lienJo": null, + "numTexteJo": null, + "idTexteJo": null, + "numJo": null, + "dateJo": null, + "idConteneur": null, + "urlCC": null, + "etat": null, + "dateDebut": null, + "dateFin": null, + "autorite": null, + "ministere": null, + "emetteur": null, + "appliGeo": null, + "codesNomenclatures": [], + "renvoi": null, + "visas": null, + "visasHtml": null, + "signataires": null, + "signatairesHtml": null, + "signataireKali": null, + "travauxPreparatoires": null, + "travauxPreparatoiresHtml": null, + "nota": null, + "notaHtml": null, + "texte": "Vu la procédure suivante : M. A...B...a demandé au tribunal administratif de Rennes d'annuler la décision du 15 octobre 2014 par laquelle la caisse d'allocations familiales les Côtes-d'Armor, après avis de la commission de recours amiable, a refusé de lui accorder le bénéfice de l'aide personnalisée au logement. Par un jugement n° 1405338 du 4 février 2016, le tribunal administratif a fait droit à cette demande. Par un pourvoi sommaire et un mémoire complémentaire, enregistrés au secrétariat du contentieux du Conseil d'Etat le 6 avril et le 5 juillet 2016, le ministre du logement et de l'habitat durable demande au Conseil d'Etat d'annuler ce jugement. Vu les autres pièces du dossier ; Vu : - le code civil ; - le code de la construction et de l'habitation ; - le code de la sécurité sociale ; - le code de justice administrative ; Après avoir entendu en séance publique : - le rapport de M. Alain Seban, conseiller d'Etat, - les conclusions de M. Nicolas Polge, rapporteur public. 1. Considérant qu'il ressort des pièces du dossier soumis au juge du fond que M. B... a sollicité de la caisse d'allocations familiales des Côtes d'Armor le bénéfice de l'aide personnalisée au logement ; que cette demande a été rejetée par une décision du 16 mai 2014, confirmée sur recours amiable le 15 octobre 2014 ; que le ministre du logement et de l'habitat durable se pourvoit en cassation contre le jugement du 4 février 2016 par lequel le magistrat délégué par le président du tribunal administratif de Rennes a annulé cette décision au motif que la caisse d'allocations familiales n'avait pu légalement se fonder sur la circonstance que la fille de l'intéressé résidait alternativement chez ses deux parents séparés pour refuser de la prendre en compte pour l'application du barème de l'aide ; 2. Considérant que l'article L. 351-3 du code de la construction et de l'habitation dispose que l'aide personnalisée au logement est calculée en fonction d'un barème qui prend notamment en compte \" la situation de famille du demandeur de l'aide occupant le logement et le nombre de personnes à charge vivant habituellement au foyer \" ; qu'aux termes de l'article R. 351-8 du même code : \" Sont considérés comme personnes à charge au sens des titres III à V du présent livre, sous réserve qu'ils vivent habituellement au foyer : / 1° Les enfants ouvrant droit aux prestations familiales (...) \" ; 3. Considérant, d'une part, qu'aux termes du deuxième alinéa de l'article L. 521-2 du code de la sécurité sociale : \" En cas de résidence alternée de l'enfant au domicile de chacun des parents telle que prévue à l'article 373-2-9 du code civil, mise en oeuvre de manière effective, (...) la charge de l'enfant pour le calcul des allocations familiales est partagée par moitié entre les deux parents soit sur demande conjointe des parents, soit si les parents sont en désaccord sur la désignation de l'allocataire \" ; qu'il résulte de ces dispositions que les enfants en situation de résidence alternée sont pris en compte pour le calcul des allocations familiales ; qu'ainsi, le ministre n'est pas fondé à soutenir qu'un \" principe d'unicité de l'allocataire \" s'opposerait à la prise en compte de ces enfants pour la détermination du montant de l'aide personnalisée au logement ; 4. Considérant, d'autre part, que, pour l'application des articles L. 351-3 et R. 351-8 du code de la construction et de l'habitation cités ci-dessus, les enfants en situation de garde alternée doivent être regardés comme vivant habituellement au foyer de chacun de leurs deux parents ; qu'ils doivent, par suite, être pris en compte pour le calcul de l'aide personnalisée au logement sollicitée, le cas échéant, par chacun des deux parents, qui ne peut toutefois prétendre à une aide déterminée sur cette base qu'au titre de la période cumulée pendant laquelle il accueille l'enfant à son domicile au cours de l'année ; 5. Considérant qu'il résulte de ce qui précède que le ministre du logement et de l'habitat durable n'est pas fondé à demander l'annulation du jugement qu'il attaque ; D E C I D E : -------------- Article 1er : Le pourvoi du ministre du logement et de l'habitat durable est rejeté. Article 2 : La présente décision sera notifiée au ministre de la cohésion des territoires, à M. A... B...et à la caisse d'allocations familiales des Côtes-d'Armor. ECLI:FR:CECHR:2017:398563.20170721", + "texteHtml": "Vu la procédure suivante :

\n M. A...B...a demandé au tribunal administratif de Rennes d'annuler la décision du 15 octobre 2014 par laquelle la caisse d'allocations familiales les Côtes-d'Armor, après avis de la commission de recours amiable, a refusé de lui accorder le bénéfice de l'aide personnalisée au logement. Par un jugement n° 1405338 du 4 février 2016, le tribunal administratif a fait droit à cette demande.

\n Par un pourvoi sommaire et un mémoire complémentaire, enregistrés au secrétariat du contentieux du Conseil d'Etat le 6 avril et le 5 juillet 2016, le ministre du logement et de l'habitat durable demande au Conseil d'Etat d'annuler ce jugement.


\n Vu les autres pièces du dossier ;

\n Vu :

\n - le code civil ;

\n - le code de la construction et de l'habitation ;

\n - le code de la sécurité sociale ;

\n - le code de justice administrative ;



\n Après avoir entendu en séance publique :

\n - le rapport de M. Alain Seban, conseiller d'Etat,

\n - les conclusions de M. Nicolas Polge, rapporteur public.




1. Considérant qu'il ressort des pièces du dossier soumis au juge du fond que M. B... a sollicité de la caisse d'allocations familiales des Côtes d'Armor le bénéfice de l'aide personnalisée au logement ; que cette demande a été rejetée par une décision du 16 mai 2014, confirmée sur recours amiable le 15 octobre 2014 ; que le ministre du logement et de l'habitat durable se pourvoit en cassation contre le jugement du 4 février 2016 par lequel le magistrat délégué par le président du tribunal administratif de Rennes a annulé cette décision au motif que la caisse d'allocations familiales n'avait pu légalement se fonder sur la circonstance que la fille de l'intéressé résidait alternativement chez ses deux parents séparés pour refuser de la prendre en compte pour l'application du barème de l'aide ;

\n 2. Considérant que l'article L. 351-3 du code de la construction et de l'habitation dispose que l'aide personnalisée au logement est calculée en fonction d'un barème qui prend notamment en compte \" la situation de famille du demandeur de l'aide occupant le logement et le nombre de personnes à charge vivant habituellement au foyer \" ; qu'aux termes de l'article R. 351-8 du même code : \" Sont considérés comme personnes à charge au sens des titres III à V du présent livre, sous réserve qu'ils vivent habituellement au foyer : / 1° Les enfants ouvrant droit aux prestations familiales (...) \" ;

\n 3. Considérant, d'une part, qu'aux termes du deuxième alinéa de l'article L. 521-2 du code de la sécurité sociale : \" En cas de résidence alternée de l'enfant au domicile de chacun des parents telle que prévue à l'article 373-2-9 du code civil, mise en oeuvre de manière effective, (...) la charge de l'enfant pour le calcul des allocations familiales est partagée par moitié entre les deux parents soit sur demande conjointe des parents, soit si les parents sont en désaccord sur la désignation de l'allocataire \" ; qu'il résulte de ces dispositions que les enfants en situation de résidence alternée sont pris en compte pour le calcul des allocations familiales ; qu'ainsi, le ministre n'est pas fondé à soutenir qu'un \" principe d'unicité de l'allocataire \" s'opposerait à la prise en compte de ces enfants pour la détermination du montant de l'aide personnalisée au logement ;

\n 4. Considérant, d'autre part, que, pour l'application des articles L. 351-3 et R. 351-8 du code de la construction et de l'habitation cités ci-dessus, les enfants en situation de garde alternée doivent être regardés comme vivant habituellement au foyer de chacun de leurs deux parents ; qu'ils doivent, par suite, être pris en compte pour le calcul de l'aide personnalisée au logement sollicitée, le cas échéant, par chacun des deux parents, qui ne peut toutefois prétendre à une aide déterminée sur cette base qu'au titre de la période cumulée pendant laquelle il accueille l'enfant à son domicile au cours de l'année ;

\n 5. Considérant qu'il résulte de ce qui précède que le ministre du logement et de l'habitat durable n'est pas fondé à demander l'annulation du jugement qu'il attaque ;




D E C I D E :
\n --------------

\nArticle 1er : Le pourvoi du ministre du logement et de l'habitat durable est rejeté.
\nArticle 2 : La présente décision sera notifiée au ministre de la cohésion des territoires, à M. A... B...et à la caisse d'allocations familiales des Côtes-d'Armor.


ECLI:FR:CECHR:2017:398563.20170721", + "sommaire": [ + { + "id": "9A", + "abstrats": null, + "resumePrincipal": "38-03-04 Pour l'application des articles L. 351-3 et R. 351-8 du code de la construction et de l'habitation (CCH), les enfants en situation de garde alternée doivent être regardés comme vivant habituellement au foyer de chacun de leurs deux parents. Ils doivent, par suite, être pris en compte pour le calcul de l'aide personnalisée au logement sollicitée, le cas échéant, par chacun des deux parents, qui ne peut toutefois prétendre à une aide déterminée sur cette base qu'au titre de la période cumulée pendant laquelle il accueille l'enfant à son domicile au cours de l'année.", + "autreResume": null + }, + { + "id": "8A", + "abstrats": "CETAT38-03-04 LOGEMENT. AIDES FINANCIÈRES AU LOGEMENT. AIDE PERSONNALISÉE AU LOGEMENT. - CALCUL DE L'AIDE - PRISE EN COMPTE D'ENFANTS EN SITUATION DE GARDE ALTERNÉE - EXISTENCE - MODALITÉS [RJ1].
", + "resumePrincipal": null, + "autreResume": null + } + ], + "titrages": [ + "CETANOME000008361640-CETANOME000008361720-CETANOME000008361997-CETANOME000008363114" + ], + "titragesKey": [ + { + "id": "CETANOME000008361640-CETANOME000008361720-CETANOME000008361997-CETANOME000008363114" + } + ], + "natureNumero": "Texte 398563", + "dateVersement": 1526083200000, + "citationJp": "[RJ1] Rappr., s'agissant du revenu de solidarité active, CE, 19 juillet 2017, Département de Paris, n° 398911, à mentionner aux Tables.", + "citationJpHtml": "[RJ1] Rappr., s'agissant du revenu de solidarité active, CE, 19 juillet 2017, Département de Paris, n° 398911, à mentionner aux Tables.", + "liens": [], + "annePublicationBulletin": null, + "numeroPublicationBulletin": null, + "typePublicationBulletin": null, + "notice": null, + "noticeHtml": null, + "inap": null, + "typeTexte": null, + "motsCles": [], + "appellations": [], + "dossiersLegislatifs": [], + "relevantDate": 1500595200000, + "typeDecision": null, + "typeControleNormes": null, + "numLoiDef": null, + "dateLoiDef": null, + "titreLoiDef": null, + "juridictionJudiciaire": null, + "conditionDiffere": null, + "conteneurs": [], + "refInjection": "IG-20230707", + "idTechInjection": "CETATEXT000035260342", + "resume": null, + "resumeHtml": null, + "rectificatif": null, + "observations": null, + "ancienId": "JG_L_2017_07_000000398563", + "demandeur": null, + "pagePdf": null, + "infosRestructurationBranche": null, + "infosRestructurationBrancheHtml": null, + "descriptionFusion": null, + "descriptionFusionHtml": null, + "infosComplementaires": null, + "infosComplementairesHtml": null, + "notaSectionsAafficher": null + } +} diff --git a/tests/test_legifrance_queries.py b/tests/test_legifrance_queries.py index d45568d..a05c565 100644 --- a/tests/test_legifrance_queries.py +++ b/tests/test_legifrance_queries.py @@ -94,3 +94,14 @@ def test_strip_links_from_markdown(): article = _json_from_test_file("LEGIARTI000006302217.json") res = _article_from_legifrance_reply(article) assert "affichCodeArticle.do" not in res.to_markdown() + + +def test_expiry(): + article = _json_from_test_file("LEGIARTI000046790860.json") + res = _article_from_legifrance_reply(article) + assert res.is_open_ended + + # Conseil d'État texts do not expire + ceta_json_article = _json_from_test_file("CETATEXT000035260342.json") + ceta_article = _article_from_legifrance_reply(ceta_json_article) + assert ceta_article.is_open_ended