dapper-sqls 0.9.7__py3-none-any.whl → 1.2.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.
- dapper_sqls/__init__.py +4 -2
- dapper_sqls/_types.py +25 -2
- dapper_sqls/async_dapper/async_dapper.py +1 -1
- dapper_sqls/async_dapper/async_executors.py +128 -53
- dapper_sqls/builders/model/model.py +421 -36
- dapper_sqls/builders/model/utils.py +337 -45
- dapper_sqls/builders/query.py +165 -44
- dapper_sqls/builders/stored.py +16 -10
- dapper_sqls/builders/stp.py +6 -2
- dapper_sqls/config.py +41 -32
- dapper_sqls/dapper/dapper.py +1 -1
- dapper_sqls/dapper/executors.py +131 -56
- dapper_sqls/decorators.py +5 -3
- dapper_sqls/http/__init__.py +4 -0
- dapper_sqls/http/aiohttp.py +155 -0
- dapper_sqls/http/decorators.py +123 -0
- dapper_sqls/http/models.py +58 -0
- dapper_sqls/http/request.py +140 -0
- dapper_sqls/models/__init__.py +3 -5
- dapper_sqls/models/base.py +246 -20
- dapper_sqls/models/connection.py +2 -2
- dapper_sqls/models/query_field.py +214 -0
- dapper_sqls/models/result.py +315 -45
- dapper_sqls/sqlite/__init__.py +5 -1
- dapper_sqls/sqlite/async_local_database.py +168 -0
- dapper_sqls/sqlite/decorators.py +69 -0
- dapper_sqls/sqlite/installer.py +97 -0
- dapper_sqls/sqlite/local_database.py +67 -185
- dapper_sqls/sqlite/models.py +51 -1
- dapper_sqls/sqlite/utils.py +9 -0
- dapper_sqls/utils.py +18 -6
- dapper_sqls-1.2.0.dist-info/METADATA +41 -0
- dapper_sqls-1.2.0.dist-info/RECORD +40 -0
- {dapper_sqls-0.9.7.dist-info → dapper_sqls-1.2.0.dist-info}/WHEEL +1 -1
- dapper_sqls-0.9.7.dist-info/METADATA +0 -19
- dapper_sqls-0.9.7.dist-info/RECORD +0 -30
- {dapper_sqls-0.9.7.dist-info → dapper_sqls-1.2.0.dist-info}/top_level.txt +0 -0
dapper_sqls/builders/query.py
CHANGED
@@ -1,55 +1,120 @@
|
|
1
|
-
#
|
2
|
-
from typing import
|
3
|
-
from pydantic import BaseModel
|
1
|
+
# coding: utf-8
|
2
|
+
from typing import Union
|
4
3
|
from datetime import datetime, date
|
5
|
-
import
|
6
|
-
|
7
|
-
class Value:
|
8
|
-
def __init__(self, value : Union[str, int, bytes, float, datetime, date], prefix : str, suffix : str):
|
9
|
-
self.value = value
|
10
|
-
self.prefix = prefix
|
11
|
-
self.suffix = suffix
|
4
|
+
from ..models import TableBaseModel, QueryFieldBase, BaseJoinConditionField, SearchTable, JoinSearchTable
|
12
5
|
|
13
6
|
class QueryBuilder(object):
|
14
7
|
|
15
|
-
def value(value : Union[str, int, bytes, float, datetime, date], prefix = "=", suffix = ""):
|
16
|
-
json_value = json.dumps({'prefix': prefix, 'value': value, 'suffix': suffix})
|
17
|
-
return f"#QueryBuilderValue#{json_value}#QueryBuilderValue#"
|
18
|
-
|
19
8
|
@classmethod
|
20
|
-
def
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
9
|
+
def build_where_clause(cls, model: TableBaseModel, table_alias: str = "", base_table_alias : str = "") -> str:
|
10
|
+
clause_parts = []
|
11
|
+
|
12
|
+
for field_name, field_value in model:
|
13
|
+
|
14
|
+
if field_value is None:
|
15
|
+
continue
|
16
|
+
|
17
|
+
# Constrói o nome qualificado com alias, se houver
|
18
|
+
qualified_field = f"{table_alias}.{field_name}" if table_alias.strip() else field_name
|
19
|
+
|
20
|
+
if isinstance(field_value, BaseJoinConditionField):
|
21
|
+
sql = field_value.to_sql(base_table_alias, qualified_field)
|
22
|
+
if sql:
|
23
|
+
clause_parts.append(sql)
|
24
|
+
|
25
|
+
elif isinstance(field_value, QueryFieldBase):
|
26
|
+
sql = field_value.to_sql(qualified_field)
|
27
|
+
if sql:
|
28
|
+
clause_parts.append(sql)
|
29
|
+
|
30
|
+
else:
|
31
|
+
if isinstance(field_value, str):
|
32
|
+
escaped_value = field_value.replace("'", "''")
|
33
|
+
clause_parts.append(f"{qualified_field} = '{escaped_value}'")
|
34
|
+
elif isinstance(field_value, bool):
|
35
|
+
clause_parts.append(f"{qualified_field} = {'1' if field_value else '0'}")
|
36
|
+
elif isinstance(field_value, datetime):
|
37
|
+
clause_parts.append(f"{qualified_field} = '{field_value.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]}'")
|
38
|
+
elif isinstance(field_value, date):
|
39
|
+
clause_parts.append(f"{qualified_field} = '{field_value.strftime('%Y-%m-%d')}'")
|
40
|
+
elif isinstance(field_value, bytes):
|
41
|
+
# Converte os bytes em uma string hexadecimal para SQL (ex: 0x4E6574)
|
42
|
+
hex_value = field_value.hex()
|
43
|
+
clause_parts.append(f"{qualified_field} = 0x{hex_value}")
|
34
44
|
else:
|
35
|
-
|
36
|
-
|
45
|
+
clause_parts.append(f"{qualified_field} = {field_value}")
|
46
|
+
|
47
|
+
return " AND ".join(clause_parts)
|
48
|
+
|
49
|
+
@staticmethod
|
50
|
+
def format_sql_value(value):
|
51
|
+
if isinstance(value, str):
|
52
|
+
value = value.replace("'", "''")
|
53
|
+
return f"'{value}'"
|
54
|
+
elif isinstance(value, bool):
|
55
|
+
return '1' if value else '0'
|
56
|
+
elif isinstance(value, datetime):
|
57
|
+
return f"'{value.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]}'"
|
58
|
+
elif isinstance(value, date):
|
59
|
+
return f"'{value.strftime('%Y-%m-%d')}'"
|
60
|
+
elif isinstance(value, bytes):
|
61
|
+
return f"0x{value.hex()}"
|
62
|
+
elif value is None:
|
63
|
+
return "NULL"
|
64
|
+
else:
|
65
|
+
return str(value)
|
66
|
+
|
67
|
+
@staticmethod
|
68
|
+
def build_select_fields(search_table: SearchTable, table_alias: str, rename_fields: bool = True) -> str:
|
69
|
+
model_fields = search_table.model.model_fields.keys()
|
70
|
+
|
71
|
+
if not rename_fields:
|
72
|
+
# Se include estiver presente, listar colunas com alias normal
|
73
|
+
if search_table.include:
|
74
|
+
fields = [f"{table_alias}.{f}" for f in search_table.include]
|
75
|
+
else:
|
76
|
+
# Nenhuma regra definida, usar '*'
|
77
|
+
return f"{table_alias}.*"
|
78
|
+
return ", ".join(fields)
|
79
|
+
|
80
|
+
# Caso rename_fields=True
|
81
|
+
if search_table.include:
|
82
|
+
fields = search_table.include
|
83
|
+
else:
|
84
|
+
fields = list(model_fields)
|
85
|
+
|
86
|
+
select_parts = [
|
87
|
+
f"{table_alias}.{field} AS {table_alias}{field}" for field in fields
|
88
|
+
]
|
89
|
+
return ", ".join(select_parts)
|
37
90
|
|
38
91
|
@classmethod
|
39
|
-
def update(cls, model:
|
40
|
-
|
92
|
+
def update(cls, model: TableBaseModel, where : Union[str , TableBaseModel]):
|
93
|
+
model._reset_defaults()
|
94
|
+
update_data = {k: int(v) if isinstance(v, bool) else v for k, v in model.model_dump(mode="json", exclude_none=True).items()}
|
41
95
|
if not isinstance(where, str):
|
42
|
-
|
43
|
-
|
96
|
+
where._reset_defaults()
|
97
|
+
for key in where.model_dump(mode="json", exclude_none=True):
|
98
|
+
if key in update_data:
|
99
|
+
update_data.pop(key, None)
|
100
|
+
where = cls.build_where_clause(where)
|
44
101
|
|
45
|
-
set_clause = ", ".join(
|
102
|
+
set_clause = ", ".join(
|
103
|
+
f"{key} = {cls.format_sql_value(value)}"
|
104
|
+
for key, value in update_data.items()
|
105
|
+
)
|
46
106
|
sql_query = f"UPDATE {model.TABLE_NAME} SET {set_clause} WHERE {where}"
|
47
107
|
return sql_query
|
48
108
|
|
49
|
-
|
50
|
-
|
109
|
+
@classmethod
|
110
|
+
def insert(cls, model: TableBaseModel, name_column_id = 'Id'):
|
111
|
+
model._reset_defaults()
|
112
|
+
insert_data = {
|
113
|
+
k: int(v) if isinstance(v, bool) else v
|
114
|
+
for k, v in model.model_dump(mode="json", exclude_none=True).items()
|
115
|
+
}
|
51
116
|
columns = ", ".join(insert_data.keys())
|
52
|
-
values = ", ".join(
|
117
|
+
values = ", ".join(cls.format_sql_value(v) for v in insert_data.values())
|
53
118
|
sql_query = f"""
|
54
119
|
INSERT INTO {model.TABLE_NAME} ({columns})
|
55
120
|
OUTPUT INSERTED.{name_column_id} AS Id
|
@@ -58,21 +123,77 @@ class QueryBuilder(object):
|
|
58
123
|
return sql_query
|
59
124
|
|
60
125
|
@classmethod
|
61
|
-
def select(cls, model:
|
126
|
+
def select(cls, model: TableBaseModel, additional_sql : str = "" ,select_top : int= None):
|
127
|
+
model._reset_defaults()
|
62
128
|
top_clause = f"TOP ({select_top}) * " if select_top else "*"
|
63
|
-
|
64
|
-
where_clause = cls._build_where_clause(**select_data)
|
129
|
+
where_clause = cls.build_where_clause(model)
|
65
130
|
|
66
131
|
sql_query = f"SELECT {top_clause} FROM {model.TABLE_NAME}"
|
67
132
|
if where_clause:
|
68
133
|
sql_query += f" WHERE {where_clause}"
|
69
134
|
sql_query = f'{sql_query} {additional_sql}'
|
70
135
|
return sql_query
|
136
|
+
|
137
|
+
@staticmethod
|
138
|
+
def build_on_clause(join_model: TableBaseModel, join_alias: str, base_alias: str) -> str:
|
139
|
+
clause_parts = []
|
140
|
+
for field_name, field_value in join_model:
|
141
|
+
if isinstance(field_value, BaseJoinConditionField):
|
142
|
+
sql = field_value.to_sql(base_alias, f"{join_alias}.{field_name}")
|
143
|
+
if sql:
|
144
|
+
clause_parts.append(sql)
|
145
|
+
return " AND ".join(clause_parts)
|
146
|
+
|
147
|
+
@classmethod
|
148
|
+
def select_with_joins(cls, main_search: SearchTable, joins: list[JoinSearchTable] = [], additional_sql: str = "", select_top: int = None) -> str:
|
149
|
+
main_model = main_search.model
|
150
|
+
main_model._reset_defaults()
|
151
|
+
main_table = main_model.TABLE_NAME
|
152
|
+
main_table_alias = main_model.TABLE_ALIAS
|
153
|
+
top_clause = f"TOP ({select_top})" if select_top else ""
|
154
|
+
|
155
|
+
# Campos SELECT da tabela principal
|
156
|
+
select_fields = cls.build_select_fields(main_search, main_table_alias, False)
|
157
|
+
|
158
|
+
# JOINs
|
159
|
+
join_clauses = []
|
160
|
+
for join_search in joins:
|
161
|
+
join_model = join_search.model
|
162
|
+
join_model._reset_defaults()
|
163
|
+
join_table = join_model.TABLE_NAME
|
164
|
+
join_table_alias = join_model.TABLE_ALIAS
|
165
|
+
|
166
|
+
join_type = join_search.join_type.upper()
|
167
|
+
on_conditions = cls.build_on_clause(join_model, join_table_alias, main_table_alias)
|
71
168
|
|
169
|
+
# Usa o alias no JOIN
|
170
|
+
join_clause = (
|
171
|
+
f"{join_type} JOIN {join_table} AS {join_table_alias} "
|
172
|
+
f"ON {on_conditions}"
|
173
|
+
)
|
174
|
+
join_clauses.append(join_clause)
|
175
|
+
|
176
|
+
# Campos SELECT do JOIN
|
177
|
+
select_fields += ", " + cls.build_select_fields(join_search, join_table_alias)
|
178
|
+
|
179
|
+
# WHERE principal
|
180
|
+
where_clause = cls.build_where_clause(main_model, main_table_alias)
|
181
|
+
where_part = f"WHERE {where_clause}" if where_clause else ""
|
182
|
+
|
183
|
+
# SQL final
|
184
|
+
sql_query = (
|
185
|
+
f"SELECT {top_clause} {select_fields} "
|
186
|
+
f"FROM {main_table} AS {main_table_alias} "
|
187
|
+
+ " ".join(join_clauses)
|
188
|
+
+ f" {where_part} {additional_sql}"
|
189
|
+
).strip()
|
190
|
+
|
191
|
+
return sql_query
|
192
|
+
|
72
193
|
@classmethod
|
73
|
-
def delete(cls, model:
|
74
|
-
|
75
|
-
where_clause = cls.
|
194
|
+
def delete(cls, model: TableBaseModel):
|
195
|
+
model._reset_defaults()
|
196
|
+
where_clause = cls.build_where_clause(model)
|
76
197
|
if not where_clause:
|
77
198
|
raise ValueError("DELETE operation requires at least one condition.")
|
78
199
|
sql_query = f"DELETE FROM {model.TABLE_NAME} WHERE {where_clause}"
|
dapper_sqls/builders/stored.py
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
#
|
1
|
+
# coding: utf-8
|
2
2
|
from typing import Type
|
3
3
|
from pydantic import BaseModel
|
4
4
|
|
@@ -9,18 +9,21 @@ class StoredBuilder:
|
|
9
9
|
parameters = []
|
10
10
|
for field, value in kwargs.items():
|
11
11
|
if value is not None:
|
12
|
-
if isinstance(value, str):
|
13
|
-
conditions.append(f"{field} = ?")
|
14
|
-
parameters.append(value)
|
15
|
-
else:
|
16
12
|
conditions.append(f"{field} = ?")
|
17
13
|
parameters.append(value)
|
18
14
|
return " AND ".join(conditions), tuple(parameters)
|
19
15
|
|
20
16
|
@classmethod
|
21
17
|
def update(cls, model: Type[BaseModel], where: Type[BaseModel]):
|
22
|
-
|
23
|
-
|
18
|
+
model._reset_defaults()
|
19
|
+
where._reset_defaults()
|
20
|
+
update_data = {k: int(v) if isinstance(v, bool) else v for k, v in model.model_dump(mode="json", exclude_none=True).items()}
|
21
|
+
where_data = {k: int(v) if isinstance(v, bool) else v for k, v in where.model_dump(mode="json", exclude_none=True).items()}
|
22
|
+
|
23
|
+
for key in where_data:
|
24
|
+
if key in update_data:
|
25
|
+
update_data.pop(key, None)
|
26
|
+
|
24
27
|
where_clause, where_params = cls._build_where_clause(**where_data)
|
25
28
|
|
26
29
|
set_clause = ", ".join([f"{key} = ?" for key in update_data.keys()])
|
@@ -30,7 +33,8 @@ class StoredBuilder:
|
|
30
33
|
|
31
34
|
@classmethod
|
32
35
|
def insert(cls, model : Type[BaseModel], name_column_id = 'Id'):
|
33
|
-
|
36
|
+
model._reset_defaults()
|
37
|
+
insert_data = {k: int(v) if isinstance(v, bool) else v for k, v in model.model_dump(mode="json", exclude_none=True).items()}
|
34
38
|
columns = ", ".join(insert_data.keys())
|
35
39
|
values = ", ".join(["?" for _ in insert_data.values()])
|
36
40
|
sql_query = f"""
|
@@ -42,8 +46,9 @@ class StoredBuilder:
|
|
42
46
|
|
43
47
|
@classmethod
|
44
48
|
def select(cls, model : Type[BaseModel], additional_sql : str = "" ,select_top : int= None):
|
49
|
+
model._reset_defaults()
|
45
50
|
top_clause = f"TOP ({select_top}) * " if select_top else "*"
|
46
|
-
select_data = model.model_dump(exclude_none=True)
|
51
|
+
select_data = {k: int(v) if isinstance(v, bool) else v for k, v in model.model_dump(mode="json", exclude_none=True).items()}
|
47
52
|
where_clause, parameters = cls._build_where_clause(**select_data)
|
48
53
|
|
49
54
|
sql_query = f"SELECT {top_clause} FROM {model.TABLE_NAME}"
|
@@ -54,7 +59,8 @@ class StoredBuilder:
|
|
54
59
|
|
55
60
|
@classmethod
|
56
61
|
def delete(cls, model : Type[BaseModel]):
|
57
|
-
|
62
|
+
model._reset_defaults()
|
63
|
+
delete_data = {k: int(v) if isinstance(v, bool) else v for k, v in model.model_dump(mode="json", exclude_none=True).items()}
|
58
64
|
where_clause, parameters = cls._build_where_clause(**delete_data)
|
59
65
|
if not where_clause:
|
60
66
|
raise ValueError("DELETE operation requires at least one condition.")
|
dapper_sqls/builders/stp.py
CHANGED
@@ -61,6 +61,7 @@ class StpBuilder(StpBaseBuilder):
|
|
61
61
|
with self.dapper.stored(args.attempts, args.wait_timeout) as db:
|
62
62
|
result = db.fetchone(self.query, self.params)
|
63
63
|
if args.model:
|
64
|
+
args.model._reset_defaults()
|
64
65
|
return self.dapper.load(args.model, result)
|
65
66
|
return result
|
66
67
|
|
@@ -78,7 +79,8 @@ class StpBuilder(StpBaseBuilder):
|
|
78
79
|
with self.dapper.stored(args.attempts, args.wait_timeout) as db:
|
79
80
|
result = db.fetchall(self.query, self.params)
|
80
81
|
if args.model:
|
81
|
-
|
82
|
+
args.model._reset_defaults()
|
83
|
+
return self.dapper.load(args.model, result)
|
82
84
|
return result
|
83
85
|
|
84
86
|
|
@@ -110,6 +112,7 @@ class AsyncStpBuilder(StpBaseBuilder):
|
|
110
112
|
async with await self.async_dapper.stored(args.attempts, args.wait_timeout) as db:
|
111
113
|
result = await db.fetchone(self.query, self.params)
|
112
114
|
if args.model:
|
115
|
+
args.model._reset_defaults()
|
113
116
|
return self.async_dapper.load(args.model, result)
|
114
117
|
return result
|
115
118
|
|
@@ -127,7 +130,8 @@ class AsyncStpBuilder(StpBaseBuilder):
|
|
127
130
|
async with await self.async_dapper.stored(args.attempts, args.wait_timeout) as db:
|
128
131
|
result = await db.fetchall(self.query, self.params)
|
129
132
|
if args.model:
|
130
|
-
|
133
|
+
args.model._reset_defaults()
|
134
|
+
return self.async_dapper.load(args.model, result)
|
131
135
|
return result
|
132
136
|
|
133
137
|
|
dapper_sqls/config.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1
|
-
#
|
1
|
+
# coding: utf-8
|
2
2
|
from .models import ConnectionStringData
|
3
3
|
import sys
|
4
|
+
import subprocess
|
4
5
|
|
5
6
|
class Config(object):
|
6
7
|
def __init__(self, server: str, database: str, username: str, password: str, sql_version: int = None, api_environment=False, default_attempts=1, default_wait_timeout=2):
|
@@ -72,41 +73,49 @@ class Config(object):
|
|
72
73
|
|
73
74
|
@staticmethod
|
74
75
|
def get_all_odbc_driver_versions():
|
75
|
-
driver_versions = []
|
76
76
|
|
77
|
+
driver_versions = []
|
77
78
|
# Verificar se o sistema é Windows e o módulo winreg está disponível
|
78
|
-
|
79
|
+
platform : str = sys.platform
|
80
|
+
if platform:
|
81
|
+
try:
|
82
|
+
import winreg # Importar o módulo winreg se estiver disponível
|
83
|
+
|
84
|
+
# Abrir a chave onde as informações sobre os drivers ODBC estão armazenadas
|
85
|
+
key_path = r"SOFTWARE\ODBC\ODBCINST.INI"
|
86
|
+
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key_path)
|
87
|
+
|
88
|
+
# Iterar sobre as subchaves para encontrar os drivers específicos
|
89
|
+
i = 0
|
90
|
+
while True:
|
91
|
+
try:
|
92
|
+
subkey_name = winreg.EnumKey(key, i)
|
93
|
+
subkey = winreg.OpenKey(key, subkey_name)
|
94
|
+
|
95
|
+
# Verificar se a subchave contém o valor 'Driver'
|
96
|
+
try:
|
97
|
+
driver_name, _ = winreg.QueryValueEx(subkey, "Driver")
|
98
|
+
if subkey_name.startswith('ODBC Driver'):
|
99
|
+
driver_versions.append(subkey_name)
|
100
|
+
|
101
|
+
except FileNotFoundError:
|
102
|
+
pass # A subchave não possui a entrada 'Driver'
|
103
|
+
|
104
|
+
i += 1
|
105
|
+
except OSError:
|
106
|
+
break
|
107
|
+
|
108
|
+
except Exception as e:
|
109
|
+
print(f"Erro ao acessar o registro: {e}")
|
110
|
+
else:
|
79
111
|
try:
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
# Iterar sobre as subchaves para encontrar os drivers específicos
|
87
|
-
i = 0
|
88
|
-
while True:
|
89
|
-
try:
|
90
|
-
subkey_name = winreg.EnumKey(key, i)
|
91
|
-
subkey = winreg.OpenKey(key, subkey_name)
|
92
|
-
|
93
|
-
# Verificar se a subchave contém o valor 'Driver'
|
94
|
-
try:
|
95
|
-
driver_name, _ = winreg.QueryValueEx(subkey, "Driver")
|
96
|
-
if subkey_name.startswith('ODBC Driver'):
|
97
|
-
driver_versions.append(subkey_name)
|
98
|
-
|
99
|
-
except FileNotFoundError:
|
100
|
-
pass # A subchave não possui a entrada 'Driver'
|
101
|
-
|
102
|
-
i += 1
|
103
|
-
except OSError:
|
104
|
-
break
|
105
|
-
|
112
|
+
result = subprocess.run(['odbcinst', '-q', '-d'], stdout=subprocess.PIPE, text=True)
|
113
|
+
for line in result.stdout.splitlines():
|
114
|
+
line = line.strip()
|
115
|
+
if line.startswith("[ODBC Driver") and "SQL Server" in line:
|
116
|
+
driver_versions.append(line.strip("[]"))
|
106
117
|
except Exception as e:
|
107
|
-
print(f"Erro ao
|
108
|
-
else:
|
109
|
-
print("A funcionalidade de obtenção de versões de drivers ODBC não é suportada neste sistema operacional.")
|
118
|
+
print(f"Erro ao detectar drivers ODBC no Linux: {e}")
|
110
119
|
|
111
120
|
return sorted(driver_versions, key=lambda x: int(x.split()[-4]), reverse=True) if driver_versions else []
|
112
121
|
|
dapper_sqls/dapper/dapper.py
CHANGED