urban-json-csv 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,202 @@
1
+ """Plugin jslt : lecture de fichiers CSV (``loadcsv``, ``loadmcsv``).
2
+
3
+ Port de ``Bb.CustomServices.ServiceCsv.ExecuteLoadCsvSource`` du .NET. Fournit la
4
+ fonction de template ``loadcsv`` qui lit un fichier CSV et renvoie un tableau
5
+ d'objets JSON (une entrée par ligne), et ``loadmcsv`` (CSV multi-enregistrements
6
+ hiérarchique, port de ``ServicesMultiCsv`` — voir :mod:`urban_json_csv.mcsv`).
7
+
8
+ Ce paquet est découvert automatiquement par ``jslt`` via le groupe d'entry points
9
+ ``urban_json.functions`` (il suffit de l'installer). Il peut aussi être enregistré
10
+ explicitement : ``from urban_json_csv import register; register(mon_registre)``.
11
+
12
+ Écarts volontaires vs .NET (documentés) :
13
+
14
+ * La résolution de chemin du .NET (``ctx.Configuration.ResolveFile``) n'existe pas
15
+ dans le portage cœur : ``source_path`` est ouvert tel quel (absolu ou relatif au
16
+ répertoire courant). Le BOM éventuel est retiré (``utf-8-sig``).
17
+ * Les types ``decimal``/``long`` du .NET deviennent ``float``/``int`` (types JSON
18
+ Python). La détection de type (bool / entier / décimal / chaîne) est fidèle.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import csv as _csv
24
+ import unicodedata
25
+ from typing import Any
26
+
27
+ from .mcsv import execute_loadmcsv
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ __all__ = ["execute_loadcsv", "execute_loadmcsv", "register"]
32
+
33
+
34
+ def _sanitize_header(name: str) -> str:
35
+ """Assainit un nom de colonne (port de ``ServiceCsv.GetLabel``).
36
+
37
+ Même intention que le .NET (alphanumérique conservé, accents -> ASCII,
38
+ espaces/quotes -> ``_``, underscores multiples réduits), mais via la
39
+ normalisation Unicode (NFKD) plutôt qu'une table figée : tous les accents sont
40
+ traités uniformément (le .NET avait une table incomplète, p.ex. ``Â`` manquant).
41
+ """
42
+ decomposed = unicodedata.normalize("NFKD", name)
43
+ out: list[str] = []
44
+ for char in decomposed:
45
+ if unicodedata.combining(char):
46
+ continue # supprime les diacritiques (accents)
47
+ if char.isascii() and char.isalnum():
48
+ out.append(char)
49
+ elif char in (" ", "'", '"'):
50
+ out.append("_")
51
+ # tout autre caractère est supprimé
52
+ label = "".join(out)
53
+ while "___" in label:
54
+ label = label.replace("___", "_")
55
+ return label.replace("__", "_")
56
+
57
+
58
+ #: Sentinelle interne : la cellule doit être omise de l'objet résultat.
59
+ _DROP = object()
60
+
61
+
62
+ def _coerce(raw: Any, exclude_null_and_empty: bool) -> Any:
63
+ """Coerce une cellule CSV en valeur JSON (port de ``ServiceCsv.GetValue``).
64
+
65
+ Retourne la sentinelle :data:`_DROP` si la valeur doit être omise (null/vide
66
+ avec ``exclude_null_and_empty``).
67
+ """
68
+ if raw is None:
69
+ return _DROP if exclude_null_and_empty else None
70
+
71
+ text = raw.strip()
72
+ low = text.lower()
73
+
74
+ if low == "":
75
+ return _DROP if exclude_null_and_empty else ""
76
+
77
+ if low == "true":
78
+ return True
79
+ if low == "false":
80
+ return False
81
+
82
+ # Détection numérique : commence par '-' ou un chiffre (fidèle au .NET).
83
+ first = low[0]
84
+ if first == "-" or first.isdigit():
85
+ is_int = False
86
+ is_decimal = False
87
+ for index, char in enumerate(low):
88
+ if index == 0 and char == "-":
89
+ continue
90
+ if char.isdigit():
91
+ is_int = True
92
+ elif is_int and char == ".":
93
+ is_decimal = True
94
+ else:
95
+ break
96
+ if is_decimal:
97
+ try:
98
+ return float(text)
99
+ except ValueError:
100
+ pass
101
+ elif is_int:
102
+ try:
103
+ value = int(text)
104
+ except ValueError:
105
+ value = None
106
+ # Garde-fou .NET (l.ToString() == v) : on ne coerce pas "007".
107
+ if value is not None and str(value) == text:
108
+ return value
109
+
110
+ # Chaîne : valeur d'origine (déjà rognée), casse conservée.
111
+ return text
112
+
113
+
114
+ def execute_loadcsv(
115
+ ctx: Any,
116
+ source_path: str | None = None,
117
+ has_header: bool = True,
118
+ separator: str = ";",
119
+ quote: str = '"',
120
+ escape: str = "\\",
121
+ excluded_columns: str | None = None,
122
+ exclude_null_and_empty: bool = False,
123
+ ) -> list:
124
+ """Lit un fichier CSV et renvoie un tableau d'objets (port de ``loadcsv``).
125
+
126
+ Args:
127
+ source_path: chemin du fichier CSV (absolu ou relatif au répertoire courant).
128
+ has_header: la 1re ligne porte les noms de colonnes (sinon ``Column0``…).
129
+ separator: séparateur de champs (défaut ``;``).
130
+ quote: caractère de citation (défaut ``"``).
131
+ escape: caractère d'échappement (défaut ``\\``).
132
+ excluded_columns: noms de colonnes à exclure, séparés par ``separator``.
133
+ exclude_null_and_empty: omet les cellules nulles/vides (et les lignes qui
134
+ n'auraient plus aucune valeur).
135
+
136
+ Returns:
137
+ La liste des lignes, chacune sous forme de dict colonne -> valeur typée.
138
+ """
139
+ if not source_path:
140
+ raise ValueError("loadcsv: source_path est requis")
141
+
142
+ delimiter = separator[0] if separator else ";"
143
+ quotechar = quote[0] if quote else '"'
144
+ escapechar = escape[0] if escape else None
145
+
146
+ excluded: set[str] = set()
147
+ if excluded_columns:
148
+ excluded = {c for c in excluded_columns.split(delimiter) if c}
149
+
150
+ with open(source_path, newline="", encoding="utf-8-sig") as handle:
151
+ reader = _csv.reader(
152
+ handle, delimiter=delimiter, quotechar=quotechar, escapechar=escapechar
153
+ )
154
+ rows = list(reader)
155
+
156
+ if not rows:
157
+ return []
158
+
159
+ if has_header:
160
+ raw_headers = rows[0]
161
+ data_rows = rows[1:]
162
+ else:
163
+ raw_headers = [f"Column{i}" for i in range(len(rows[0]))]
164
+ data_rows = rows
165
+
166
+ # (nom_brut, nom_assaini) par colonne ; l'exclusion compare le nom brut (.NET).
167
+ columns = [
168
+ (raw, raw if not has_header else _sanitize_header(raw)) for raw in raw_headers
169
+ ]
170
+
171
+ result: list[dict] = []
172
+ for row in data_rows:
173
+ obj: dict[str, Any] = {}
174
+ for index, (raw_name, label) in enumerate(columns):
175
+ if raw_name in excluded:
176
+ continue
177
+ cell = row[index] if index < len(row) else None
178
+ value = _coerce(cell, exclude_null_and_empty)
179
+ if value is _DROP:
180
+ continue
181
+ obj[label] = value
182
+ if obj or not exclude_null_and_empty:
183
+ result.append(obj)
184
+ return result
185
+
186
+
187
+ def register(registry: Any) -> None:
188
+ """Enregistre les fonctions du plugin dans un registre jslt.
189
+
190
+ Point d'entrée déclaré via l'entry point ``urban_json.functions`` (découverte
191
+ automatique) et utilisable manuellement.
192
+ """
193
+ registry.register(
194
+ "loadcsv",
195
+ execute_loadcsv,
196
+ description="Lit un fichier CSV et renvoie un tableau d'objets.",
197
+ )
198
+ registry.register(
199
+ "loadmcsv",
200
+ execute_loadmcsv,
201
+ description="Lit un CSV multi-enregistrements hiérarchique et le convertit en arbre JSON.",
202
+ )
urban_json_csv/mcsv.py ADDED
@@ -0,0 +1,187 @@
1
+ """Lecture de CSV multi-enregistrements (``loadmcsv``).
2
+
3
+ Port de ``Bb.CustomServices.ServicesMultiCsv.LoadMultiCsv`` + la bibliothèque
4
+ ``Bb.MultiCsv`` (lexer, règles d'indentation, blocs, visiteur JSON).
5
+
6
+ Format de fichier (``;``-délimité) :
7
+
8
+ * ``#DEF;<schema>:<NOM>;col1;col2;…`` — définit les colonnes du type ``NOM`` ;
9
+ * ``#END`` — fin du fichier ;
10
+ * ``#CLE;v1;v2`` — métadonnée ``CLE`` (valeurs jointes par ``;``) ;
11
+ * ligne de données : 1er champ = type d'enregistrement, puis les valeurs des
12
+ colonnes définies par le ``#DEF`` correspondant.
13
+
14
+ Un ``rule_payload`` ``"PARENT:ENF1|ENF2; AUTRE:…"`` (règles séparées par ``;``)
15
+ décrit la **hiérarchie** : un bloc dont le nom est enfant du parent courant s'y
16
+ imbrique, sinon il remonte. Le résultat JSON groupe les enfants homonymes en
17
+ tableaux ; ``loadmcsv`` ajoute un objet ``_metadatas`` (nom de fichier, encodage,
18
+ métadonnées du fichier).
19
+
20
+ Écarts volontaires vs .NET (documentés) :
21
+
22
+ * **Encodage** : lecture en ``utf-8`` (comme le .NET, qui lit en UTF-8 malgré sa
23
+ détection Ude) ; ``_encodingFile``/``_encodingFileBody`` rapportent ``utf-8``.
24
+ * **Dernier champ** : le .NET ignore le dernier champ de chaque ligne (il suppose
25
+ un ``;`` final). Le portage mappe les colonnes par position jusqu'au nombre de
26
+ colonnes déclarées — tolérant au ``;`` final comme à son absence.
27
+ * Noms de types et de règles comparés en **majuscules** (le .NET met les libellés
28
+ en majuscules ; on fait de même côté règles pour éviter les surprises de casse).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ from typing import Any
35
+
36
+ __all__ = ["execute_loadmcsv"]
37
+
38
+
39
+ class _Block:
40
+ """Un enregistrement : nom de type, champs (colonne→valeur), sous-blocs."""
41
+
42
+ __slots__ = ("fields", "name", "subs")
43
+
44
+ def __init__(self, name: str, fields: dict[str, str]) -> None:
45
+ self.name = name
46
+ self.fields = fields
47
+ self.subs: list[_Block] = []
48
+
49
+
50
+ def _parse_rules(payload: str | None) -> dict[str, set[str]]:
51
+ """Parse le DSL de règles ``"PARENT:ENF1|ENF2; …"`` (port d'``IndentationRules``)."""
52
+ rules: dict[str, set[str]] = {}
53
+ for rule in (payload or "").strip().split(";"):
54
+ rule = rule.strip()
55
+ if not rule or ":" not in rule:
56
+ continue
57
+ key, children = rule.split(":", 1)
58
+ kids = {c.strip().upper() for c in children.split("|") if c.strip()}
59
+ rules.setdefault(key.strip().upper(), set()).update(kids)
60
+ return rules
61
+
62
+
63
+ def _read_blocks(path: str, encoding: str) -> tuple[list[_Block], dict[str, str]]:
64
+ """Lit le fichier en blocs de données + métadonnées (port du ``Lexer``)."""
65
+ headers: dict[str, list[str]] = {}
66
+ metadata: dict[str, str] = {}
67
+ blocks: list[_Block] = []
68
+
69
+ with open(path, encoding=encoding, newline="") as handle:
70
+ for raw in handle:
71
+ line = raw.rstrip("\r\n")
72
+ if line == "":
73
+ continue
74
+ fields = line.split(";")
75
+ tag = fields[0]
76
+
77
+ if tag.startswith("#"):
78
+ if tag == "#DEF":
79
+ # fields[1] = "<schema>:<NOM>" ; fields[2:] = colonnes
80
+ name = fields[1].split(":")[-1].strip().upper() if len(fields) > 1 else ""
81
+ headers[name] = [c.strip() for c in fields[2:] if c.strip()]
82
+ elif tag == "#END":
83
+ break
84
+ else:
85
+ metadata[tag[1:]] = ";".join(fields[1:]).strip(";")
86
+ continue
87
+
88
+ name = tag.strip().upper()
89
+ columns = headers.get(name)
90
+ field_map: dict[str, str] = {}
91
+ if columns:
92
+ for index, column in enumerate(columns):
93
+ field_map[column] = fields[index + 1] if index + 1 < len(fields) else ""
94
+ blocks.append(_Block(name, field_map))
95
+
96
+ return blocks, metadata
97
+
98
+
99
+ def _build_tree(blocks: list[_Block], rules: dict[str, set[str]]) -> list[_Block]:
100
+ """Construit l'arbre de blocs par pile (port de ``FileReader.Read``)."""
101
+ if not blocks:
102
+ return []
103
+
104
+ def embedded(parent: _Block, child: _Block) -> bool:
105
+ return child.name in rules.get(parent.name, ())
106
+
107
+ stack: list[_Block] = [blocks[0]]
108
+ for current in blocks[1:]:
109
+ parent = stack[-1]
110
+ if embedded(parent, current):
111
+ parent.subs.append(current)
112
+ stack.append(current)
113
+ else:
114
+ parent = stack.pop()
115
+ while stack and not embedded(parent, current):
116
+ parent = stack.pop()
117
+ parent.subs.append(current)
118
+ stack.append(parent)
119
+ stack.append(current)
120
+
121
+ items: list[_Block] = []
122
+ while stack:
123
+ current = stack.pop()
124
+ if not stack or all(current is not sub for sub in stack[-1].subs):
125
+ items.append(current)
126
+ return items
127
+
128
+
129
+ def _to_json(block: _Block, seen: set[int], ignore_empty: bool) -> dict[str, Any]:
130
+ """Convertit un bloc en objet JSON (port de ``JsonVisitor``)."""
131
+ result: dict[str, Any] = {}
132
+ for sub in block.subs:
133
+ array = result.setdefault(sub.name, [])
134
+ if id(sub) not in seen:
135
+ seen.add(id(sub))
136
+ array.append(_to_json(sub, seen, ignore_empty))
137
+ for key, value in block.fields.items():
138
+ if not ignore_empty or value != "":
139
+ result[key] = value
140
+ return result
141
+
142
+
143
+ def execute_loadmcsv(
144
+ ctx: Any,
145
+ source_path: str | None = None,
146
+ rule_payload: str | None = None,
147
+ encoding: str = "utf-8-sig",
148
+ ignore_empty: bool = False,
149
+ ) -> Any:
150
+ """Lit un CSV multi-enregistrements et le convertit en arbre JSON (port ``loadmcsv``).
151
+
152
+ Args:
153
+ source_path: chemin du fichier.
154
+ rule_payload: règles d'imbrication ``"PARENT:ENF1|ENF2; …"``.
155
+ encoding: encodage de lecture (défaut ``utf-8-sig``, retire le BOM).
156
+ ignore_empty: omet les propriétés vides.
157
+
158
+ Returns:
159
+ ``None`` si aucun enregistrement, l'objet racine s'il y en a un seul,
160
+ sinon la liste des objets racines. Chaque racine porte ``_metadatas``.
161
+ """
162
+ if not source_path:
163
+ raise ValueError("loadmcsv: source_path est requis")
164
+
165
+ blocks, metadata = _read_blocks(source_path, encoding)
166
+ rules = _parse_rules(rule_payload)
167
+ items = _build_tree(blocks, rules)
168
+
169
+ meta_obj: dict[str, Any] = {
170
+ "_filename": os.path.abspath(source_path),
171
+ "_encodingFile": "utf-8",
172
+ "_encodingFileBody": "utf-8",
173
+ }
174
+ meta_obj.update(metadata)
175
+
176
+ seen: set[int] = set()
177
+ datas: list[Any] = []
178
+ for block in items:
179
+ obj = _to_json(block, seen, ignore_empty)
180
+ obj["_metadatas"] = meta_obj
181
+ datas.append(obj)
182
+
183
+ if not datas:
184
+ return None
185
+ if len(datas) == 1:
186
+ return datas[0]
187
+ return datas
File without changes
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: urban-json-csv
3
+ Version: 0.1.0
4
+ Summary: Plugin urban-json : lecture de fichiers CSV (fonction de template loadcsv).
5
+ Author-email: Gael Beard <gaelgael5@gmail.com>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/gaelgael5/Jslt
8
+ Project-URL: Repository, https://github.com/gaelgael5/Jslt
9
+ Keywords: json,transform,template,jslt,csv,plugin
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Topic :: Text Processing :: Markup
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: urban-json
26
+ Dynamic: license-file
27
+
28
+ # urban-json-csv
29
+
30
+ Plugin [urban-json](https://github.com/gaelgael5/Jslt) : lecture de fichiers **CSV**
31
+ dans les templates, via la fonction `loadcsv`. Port de
32
+ `Bb.CustomServices.ServiceCsv` (.NET).
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install urban-json-csv # tire urban-json en dépendance
38
+ ```
39
+
40
+ Rien d'autre à faire : `urban-json` découvre le plugin automatiquement via les entry
41
+ points (`urban_json.functions`). `loadcsv` devient utilisable dans les templates.
42
+
43
+ ## Utilisation
44
+
45
+ ```python
46
+ from urban_json import JsltTemplate
47
+
48
+ # people.csv :
49
+ # name;age;active
50
+ # Alice;30;true
51
+ # Bob;25;false
52
+ tpl = JsltTemplate.parse('[ { "$source": loadcsv("people.csv"), "n": $.name, "a": $.age } ]')
53
+ tpl.transform({})
54
+ # -> [{'n': 'Alice', 'a': 30}, {'n': 'Bob', 'a': 25}]
55
+ ```
56
+
57
+ `loadcsv` renvoie un tableau d'objets (une entrée par ligne) ; il s'enchaîne donc
58
+ naturellement avec une boucle `$source`, un filtre `?(...)`, etc.
59
+
60
+ ## Signature
61
+
62
+ ```
63
+ loadcsv(source_path, has_header=true, separator=";", quote="\"", escape="\\",
64
+ excluded_columns=null, exclude_null_and_empty=false)
65
+ ```
66
+
67
+ | Paramètre | Rôle |
68
+ |---|---|
69
+ | `source_path` | chemin du fichier CSV (absolu ou relatif au répertoire courant) |
70
+ | `has_header` | la 1re ligne porte les noms de colonnes (sinon `Column0`, `Column1`…) |
71
+ | `separator` | séparateur de champs (défaut `;`) |
72
+ | `quote` | caractère de citation (défaut `"`) |
73
+ | `escape` | caractère d'échappement (défaut `\`) |
74
+ | `excluded_columns` | noms de colonnes à exclure, séparés par `separator` |
75
+ | `exclude_null_and_empty` | omet les cellules nulles/vides |
76
+
77
+ ### Typage des valeurs
78
+
79
+ Les cellules sont coercées comme en .NET : `true`/`false` → booléen,
80
+ entiers/décimaux → nombres, le reste → chaîne (les entêtes sont assainis :
81
+ accents → ASCII, espaces → `_`).
82
+
83
+ ## `loadmcsv` — CSV multi-enregistrements hiérarchique
84
+
85
+ Port de `ServicesMultiCsv` : un fichier `;`-délimité où chaque ligne est préfixée
86
+ par un **type d'enregistrement**, les colonnes de chaque type étant déclarées par
87
+ des lignes `#DEF`. Un `rule_payload` décrit la **hiérarchie**.
88
+
89
+ ```
90
+ #DEF;CATALOG;id;name
91
+ #DEF;BOOK;code;title
92
+ CATALOG;1;Librairie
93
+ BOOK;a;Alpha
94
+ BOOK;b;Beta
95
+ #END
96
+ ```
97
+
98
+ ```python
99
+ from urban_json import JsltTemplate
100
+ JsltTemplate.parse(
101
+ '[ { "$source": selectmany(loadmcsv("catalog.mcsv", "CATALOG:BOOK"), $.BOOK.*), "t": $.title } ]'
102
+ ).transform({})
103
+ # -> [{'t': 'Alpha'}, {'t': 'Beta'}]
104
+ ```
105
+
106
+ `loadmcsv(source_path, rule_payload, encoding, ignore_empty)` :
107
+ - `rule_payload` : règles `"PARENT:ENF1|ENF2; …"` (séparées par `;`). Un bloc dont
108
+ le nom est enfant du parent courant s'y imbrique, sinon il remonte.
109
+ - Les enfants homonymes deviennent un **tableau** ; chaque racine porte un objet
110
+ `_metadatas` (nom de fichier, encodage, métadonnées `#clé` du fichier).
111
+ - Lignes `#DEF;<schema>:<NOM>;col…` (colonnes), `#clé;…` (métadonnée), `#END` (fin).
112
+
113
+ Écarts assumés vs .NET : lecture `utf-8` ; mappage des colonnes par position
114
+ tolérant au `;` final (le .NET l'exige) ; noms de types comparés en majuscules.
115
+
116
+ ## Licence
117
+
118
+ Apache-2.0.
@@ -0,0 +1,9 @@
1
+ urban_json_csv/__init__.py,sha256=rjcBsVpGzfvyuilkK5-FWrEMPKVRkHgn90hr2w_JxeI,7000
2
+ urban_json_csv/mcsv.py,sha256=HiCNgl-rKHTuR4lG1WhLD88H9NeYhCXoXo_BF9vg7m4,6796
3
+ urban_json_csv/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ urban_json_csv-0.1.0.dist-info/licenses/LICENSE,sha256=LdXMQzg0w6hrGI_vhgHsmOLKcHTkyF5PyUXkP9fV7mA,1068
5
+ urban_json_csv-0.1.0.dist-info/METADATA,sha256=c-x-cmVx1x8MVfQlqC_UBsoIeq-tx1PzeopoWYx6PiA,4198
6
+ urban_json_csv-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
7
+ urban_json_csv-0.1.0.dist-info/entry_points.txt,sha256=U3FWKPHVQ1MniD9Ukg8H7-z69TWtkBq8op5BVWLRtFk,53
8
+ urban_json_csv-0.1.0.dist-info/top_level.txt,sha256=HvN15nMA_4voCRcD50sblM93GM04fECJs8-Es-JdhVw,15
9
+ urban_json_csv-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [urban_json.functions]
2
+ csv = urban_json_csv:register
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 black beard
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ urban_json_csv