Skip to content

Commit 9034208

Browse files
committed
Added some validation functions
1 parent 698bc92 commit 9034208

11 files changed

Lines changed: 245 additions & 91 deletions

File tree

‎README.md‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,34 @@ pip install --user dsff
3636
```python
3737
>>> import dsff
3838
>>> with dsff.DSFF("/path/to/my-dataset.dsff") as f:
39-
f.to_dataset() # creates ./[dsff-title] with data.csv, features.json and metadata.json
39+
f.to_dataset() # creates ./my-dataset with data.csv, features.json and metadata.json
4040
```
4141

42+
**
43+
44+
## :heavy_plus_sign: Extensions
45+
46+
**Install all available extensions**
47+
48+
```sh
49+
pip install --user dsff[all]
50+
```
51+
52+
**Dealing with [Apache Arrow](https://arrow.apache.org/) formats**
53+
54+
```sh
55+
pip install --user dsff[arrow]
56+
```
57+
58+
```python
59+
>>> import dsff
60+
>>> with dsff.DSFF("/path/to/my-dataset.dsff") as f:
61+
f.to_feather() # creates ./my-dataset.feather
62+
f.to_orc() # creates ./my-dataset.orc
63+
f.to_parquet() # creates ./my-dataset.parquet
64+
```
65+
66+
4267
## :star: Related Projects
4368

4469
You may also like these:

‎pyproject.toml‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,8 @@ dependencies = [
3434
dynamic = ["version"]
3535

3636
[project.optional-dependencies]
37-
extra = [
38-
"pyarrow",
39-
]
37+
all = ["dsff[arrow]"]
38+
arrow = ["pyarrow"]
4039

4140
[project.readme]
4241
file = "README.md"

‎src/dsff/VERSION.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.2.0
1+
1.2.1

‎src/dsff/formats/__common__.py‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from ast import literal_eval
66
from io import BytesIO, StringIO
77
from os import makedirs, remove
8-
from os.path import basename, expanduser, isfile, isdir, join, splitext
8+
from os.path import basename, exists, expanduser, isfile, isdir, join, splitext
99
try: # pragma: no cover
1010
import pyarrow
1111
import pyarrow.feather as feather
@@ -17,11 +17,11 @@
1717
PYARROW = False
1818

1919

20-
__all__ = ["basename", "csvmod", "expanduser", "isfile", "isdir", "join", "json", "literal_eval", "makedirs",
21-
"remove", "splitext", "re", "BytesIO", "StringIO",
20+
__all__ = ["basename", "csvmod", "ensure_str", "exists", "expanduser", "isfile", "isdir", "join", "json",
21+
"literal_eval", "makedirs", "re", "remove", "splitext", "text_or_path", "BytesIO", "StringIO",
2222
"CSV_DELIMITER", "DEFAULT_EXCL", "INMEMORY", "META_EXCL", "MISSING_TOKEN", "PYARROW", "TARGET_NAME"]
2323
if PYARROW:
24-
__all__ += ["feather", "orc", "pandas", "pyarrow", "parquet"]
24+
__all__ += ["feather", "orc", "pandas", "parquet", "pyarrow"]
2525

2626

2727
CSV_DELIMITER = ";"
@@ -31,3 +31,26 @@
3131
MISSING_TOKEN = "?"
3232
TARGET_NAME = "label"
3333

34+
35+
def ensure_str(string):
36+
try:
37+
return string.decode()
38+
except AttributeError:
39+
return str(string)
40+
41+
42+
def text_or_path(f):
43+
def _wrapper(txt_path, *args, **kwargs):
44+
if not isinstance(txt_path, bytes):
45+
if isinstance(txt_path, str):
46+
if not re.search(r"[<>:\'\"/\\|?*\x00-\x1f]", txt_path, re.I) and exists(txt_path):
47+
if not isdir(txt_path):
48+
with open(txt_path, "rb") as fh:
49+
txt_path = fh.read()
50+
else:
51+
txt_path = txt_path.encode()
52+
else:
53+
raise ValueError(f"Bad input text or path ({type(txt_path)})")
54+
return f(txt_path, *args, **kwargs)
55+
return _wrapper
56+

‎src/dsff/formats/__init__.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222

2323

2424
__all__ = ["DSFF"]
25+
for k in list(globals().keys()):
26+
if k.startswith("is_"):
27+
__all__.append(k)
2528

2629
_FORMAT_TEXT_ALIAS = {'arff': "ARFF", 'csv': "CSV", 'db': "SQL", 'orc': "ORC"}
2730
_NO_EXT_FORMAT = ["dataset"]
@@ -343,4 +346,16 @@ def path(self):
343346
if p != INMEMORY and not p.endswith(".dsff"):
344347
p += ".dsff"
345348
return p
349+
350+
@staticmethod
351+
@text_or_path
352+
def format(text):
353+
for k, func in globals().items():
354+
if k.startswith("is_") and func(text):
355+
return k.split("_", 1)[1]
356+
357+
@staticmethod
358+
@text_or_path
359+
def is_valid_format(text):
360+
return DSFF.format(text) is not None
346361

‎src/dsff/formats/arff.py‎

Lines changed: 76 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -2,68 +2,66 @@
22
from .__common__ import *
33

44

5-
__all__ = ["from_arff", "to_arff"]
5+
__all__ = ["from_arff", "is_arff", "to_arff"]
66

77

8-
def from_arff(dsff, path=None, target=TARGET_NAME, missing=MISSING_TOKEN):
9-
""" Populate the DSFF file from an ARFF file. """
10-
d, features = [], {}
11-
with open(path) as f:
12-
relation, attributes, data = False, [False, False], False
13-
for n, l in enumerate(f, 1):
14-
l, pf = l.strip(), f"Line {n}: "
15-
# the file shall start with "@RELATION"
16-
if not relation:
17-
if l.startswith("@RELATION "):
18-
relation = True
19-
try:
20-
dsff['title'] = re.match(r"@RELATION\s+('[^']*'|\"[^\"]*\")$", l).group(1).strip("'\"")
21-
continue
22-
except Exception as e:
23-
raise BadInputData(f"{pf}failed on @RELATION ({e})")
24-
else:
25-
raise BadInputData(f"{pf}did not find @RELATION")
26-
# get metadata and feature descriptions from comments
27-
if l.startswith("%"):
28-
if re.match(r"^\%\s+metadata\s*\:\s*\{.*\}$", l):
29-
dsff.write(metadata=literal_eval(l.split(":", 1)[1]))
30-
elif (m := re.match(r"^\%\s+(.*?)\s*\:\s*(.*?)$", l)):
31-
name, descr = m.groups()
32-
features[name] = descr
33-
continue
34-
# then ignore blank lines
35-
if l == "":
36-
if attributes[0] and not attributes[1]:
37-
# close the atributes block
38-
attributes[1] = True
39-
n_cols = len(d[0])
40-
continue
41-
if l.startswith("@ATTRIBUTE "):
42-
if not attributes[0]:
43-
attributes[0] = True
44-
if len(d) == 0:
45-
# start the attributes block
46-
d.append([])
47-
if attributes[1]:
48-
raise BadInputData(f"{pf}found @ATTRIBUTE out of the attributes block)")
8+
def _parse(text_or_fh, target=TARGET_NAME, missing=MISSING_TOKEN):
9+
d, features, metadata, title = [], {}, {}, ""
10+
relation, attributes, data = False, [False, False], False
11+
for n, l in enumerate(t.splitlines() if isinstance(t := text_or_fh, str) else t):
12+
l, pf = l.strip(), f"Line {n}: "
13+
# the file shall start with "@RELATION"
14+
if not relation:
15+
if l.startswith("@RELATION "):
16+
relation = True
4917
try:
50-
header = re.match(r"@ATTRIBUTE\s+([^\s]+)\s+[A-Z]+$", l).group(1)
51-
if header == "class":
52-
header = target
53-
d[0].append(header)
54-
continue
55-
except AttributeError:
56-
raise BadInputData(f"{pf}failed on @ATTRIBUTE (bad type)")
57-
if not data:
58-
if l == "@DATA":
59-
data = True
18+
title = re.match(r"@RELATION\s+('[^']*'|\"[^\"]*\")$", l).group(1).strip("'\"")
6019
continue
61-
else:
62-
raise BadInputData(f"{pf}did not find @DATA where expected")
63-
row = list(map(lambda x: x.strip("'\""), re.split(r",\s+", l)))
64-
if len(row) != n_cols:
65-
raise BadInputData(f"{pf}this row does not match the number of columns")
66-
d.append(row)
20+
except Exception as e:
21+
raise BadInputData(f"{pf}failed on @RELATION ({e})")
22+
else:
23+
raise BadInputData(f"{pf}did not find @RELATION")
24+
# get metadata and feature descriptions from comments
25+
if l.startswith("%"):
26+
if re.match(r"^\%\s+metadata\s*\:\s*\{.*\}$", l):
27+
metadata = literal_eval(l.split(":", 1)[1])
28+
elif (m := re.match(r"^\%\s+(.*?)\s*\:\s*(.*?)$", l)):
29+
name, descr = m.groups()
30+
features[name] = descr
31+
continue
32+
# then ignore blank lines
33+
if l == "":
34+
if attributes[0] and not attributes[1]:
35+
# close the atributes block
36+
attributes[1] = True
37+
n_cols = len(d[0])
38+
continue
39+
if l.startswith("@ATTRIBUTE "):
40+
if not attributes[0]:
41+
attributes[0] = True
42+
if len(d) == 0:
43+
# start the attributes block
44+
d.append([])
45+
if attributes[1]:
46+
raise BadInputData(f"{pf}found @ATTRIBUTE out of the attributes block)")
47+
try:
48+
header = re.match(r"@ATTRIBUTE\s+([^\s]+)\s+[A-Z]+$", l).group(1)
49+
if header == "class":
50+
header = target
51+
d[0].append(header)
52+
continue
53+
except AttributeError:
54+
raise BadInputData(f"{pf}failed on @ATTRIBUTE (bad type)")
55+
if not data:
56+
if l == "@DATA":
57+
data = True
58+
continue
59+
else:
60+
raise BadInputData(f"{pf}did not find @DATA where expected")
61+
row = list(map(lambda x: x.strip("'\""), re.split(r",\s+", l)))
62+
if len(row) != n_cols:
63+
raise BadInputData(f"{pf}this row does not match the number of columns")
64+
d.append(row)
6765
for i in range(n_cols):
6866
values = []
6967
for j, row in enumerate(d):
@@ -76,9 +74,26 @@ def from_arff(dsff, path=None, target=TARGET_NAME, missing=MISSING_TOKEN):
7674
if "".join(sorted(set(values))) in ["0", "1", "01"]:
7775
for j, row in enumerate(d):
7876
if j > 0:
79-
row[i] = {'0': "False", '1': "True"}[row[i]]
80-
dsff.write(data=d)
81-
dsff.write(features=features)
77+
row[i] = {'0': "False", '1': "True"}[row[i]]
78+
return d, features, metadata, title
79+
80+
81+
def from_arff(dsff, path=None, target=TARGET_NAME, missing=MISSING_TOKEN):
82+
""" Populate the DSFF file from an ARFF file. """
83+
with open(path) as f:
84+
d, ft, md, t = _parse(f, target, missing)
85+
dsff['title'] = t
86+
dsff.write(data=d, features=ft, metadata=md)
87+
88+
89+
@text_or_path
90+
def is_arff(text):
91+
""" Check if the input text or path is a valid ARFF. """
92+
try:
93+
_parse(ensure_str(text))
94+
return True
95+
except (BadInputData, UnicodeDecodeError):
96+
return False
8297

8398

8499
def to_arff(dsff, path=None, target=TARGET_NAME, exclude=DEFAULT_EXCL, missing=MISSING_TOKEN, text=False):

‎src/dsff/formats/csv.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from .__common__ import *
33

44

5-
__all__ = ["from_csv", "to_csv"]
5+
__all__ = ["from_csv", "is_csv", "to_csv"]
66

77

88
def from_csv(dsff, path=None, exclude=DEFAULT_EXCL):
@@ -18,6 +18,17 @@ def from_csv(dsff, path=None, exclude=DEFAULT_EXCL):
1818
dsff.write(features=features)
1919

2020

21+
@text_or_path
22+
def is_csv(text):
23+
""" Check if the input text or path is a valid CSV. """
24+
try:
25+
dialect = csvmod.Sniffer().sniff(text := ensure_str(text))
26+
csvmod.Sniffer().has_header(text)
27+
return True
28+
except (csvmod.Error, UnicodeDecodeError):
29+
return False
30+
31+
2132
def to_csv(dsff, path=None, text=False):
2233
""" Create a CSV from the data worksheet, saved as a file or output as a string. """
2334
with (StringIO() if text else open(path, 'w+')) as f:

‎src/dsff/formats/dataset.py‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,32 @@
22
from .__common__ import *
33

44

5-
__all__ = ["from_dataset", "to_dataset"]
5+
__all__ = ["from_dataset", "is_dataset", "to_dataset"]
66

77

8-
def from_dataset(dsff, path=None):
9-
""" Populate the DSFF file from a Dataset structure. """
8+
def _parse(path):
109
if not isdir(path):
1110
raise BadInputData("Not a folder")
1211
else:
1312
if len(missing := [f for f in ["data.csv", "features.json", "metadata.json"] if not isfile(join(path, f))]) > 0:
1413
raise BadInputData(f"Not a valid dataset folder (missing: {', '.join(missing)})")
14+
15+
16+
def from_dataset(dsff, path=None):
17+
""" Populate the DSFF file from a Dataset structure. """
18+
_parse(path)
1519
dsff.write(path)
1620

1721

22+
def is_dataset(path):
23+
""" Check if the input path is a valid Dataset. """
24+
try:
25+
_parse(path)
26+
return True
27+
except BadInputData:
28+
return False
29+
30+
1831
def to_dataset(dsff, path=None):
1932
""" Create a dataset folder according to the following structure ;
2033
name

‎src/dsff/formats/db.py‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from .__common__ import *
33

44

5-
__all__ = ["from_db", "to_db"]
5+
__all__ = ["from_db", "is_db", "to_db"]
66

77

88
def from_db(dsff, path=None, exclude=DEFAULT_EXCL):
@@ -30,6 +30,36 @@ def from_db(dsff, path=None, exclude=DEFAULT_EXCL):
3030
conn.close()
3131

3232

33+
@text_or_path
34+
def is_db(data):
35+
""" Check if the input data or path is a valid SQL database. """
36+
from sqlite3 import connect, Error
37+
from sys import version_info
38+
if not data.startswith(b"SQLite format 3\x00"):
39+
return False
40+
if version_info.minor > 10:
41+
try:
42+
with connect(":memory:") as c:
43+
c.deserialize(data)
44+
c.execute("PRAGMA schema_version;")
45+
return True
46+
except Error:
47+
return False
48+
else: # pragma: no cover
49+
from os import close, write
50+
from tempfile import mkstemp
51+
fd, path = mkstemp(suffix=".db")
52+
try:
53+
write(fd, data)
54+
close(fd)
55+
fd = None
56+
with connect(f"file:{path}?mode=ro&immutable=1", uri=True) as c:
57+
c.execute("PRAGMA schema_version;").fetchone()
58+
return True
59+
except (Error, OSError):
60+
return False
61+
62+
3363
def to_db(dsff, path=None, text=False, primary_index=0):
3464
""" Create a SQLDB from the data worksheet, saved as a file or output as a string. """
3565
from json import dumps

0 commit comments

Comments
 (0)