django-fast-treenode 2.0.9__py3-none-any.whl → 2.0.11__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.
- {django_fast_treenode-2.0.9.dist-info → django_fast_treenode-2.0.11.dist-info}/METADATA +4 -3
- {django_fast_treenode-2.0.9.dist-info → django_fast_treenode-2.0.11.dist-info}/RECORD +19 -18
- treenode/admin.py +95 -71
- treenode/docs/Documentation +7 -35
- treenode/forms.py +64 -23
- treenode/managers.py +306 -168
- treenode/models/closure.py +22 -46
- treenode/models/proxy.py +43 -24
- treenode/templates/admin/tree_node_changelist.html +2 -0
- treenode/templates/admin/tree_node_import.html +27 -9
- treenode/templates/admin/tree_node_import_report.html +32 -0
- treenode/utils/__init__.py +4 -5
- treenode/utils/exporter.py +80 -27
- treenode/utils/importer.py +185 -152
- treenode/views.py +18 -12
- treenode/widgets.py +21 -5
- {django_fast_treenode-2.0.9.dist-info → django_fast_treenode-2.0.11.dist-info}/LICENSE +0 -0
- {django_fast_treenode-2.0.9.dist-info → django_fast_treenode-2.0.11.dist-info}/WHEEL +0 -0
- {django_fast_treenode-2.0.9.dist-info → django_fast_treenode-2.0.11.dist-info}/top_level.txt +0 -0
treenode/utils/importer.py
CHANGED
@@ -12,7 +12,7 @@ Features:
|
|
12
12
|
- Uses bulk operations for efficient data insertion and updates.
|
13
13
|
- Supports transactional imports to maintain data integrity.
|
14
14
|
|
15
|
-
Version: 2.0.
|
15
|
+
Version: 2.0.11
|
16
16
|
Author: Timur Kady
|
17
17
|
Email: timurkady@yandex.com
|
18
18
|
"""
|
@@ -21,10 +21,11 @@ Email: timurkady@yandex.com
|
|
21
21
|
import csv
|
22
22
|
import json
|
23
23
|
import yaml
|
24
|
+
import openpyxl
|
24
25
|
import math
|
25
|
-
import
|
26
|
+
import uuid
|
26
27
|
from io import BytesIO, StringIO
|
27
|
-
|
28
|
+
|
28
29
|
import logging
|
29
30
|
|
30
31
|
logger = logging.getLogger(__name__)
|
@@ -55,13 +56,13 @@ class TreeNodeImporter:
|
|
55
56
|
self.file_content = file.read()
|
56
57
|
|
57
58
|
def get_text_content(self):
|
58
|
-
"""
|
59
|
+
"""Return the contents of a file as a string."""
|
59
60
|
if isinstance(self.file_content, bytes):
|
60
61
|
return self.file_content.decode("utf-8")
|
61
62
|
return self.file_content
|
62
63
|
|
63
64
|
def import_data(self):
|
64
|
-
"""
|
65
|
+
"""Import data and returns a list of dictionaries."""
|
65
66
|
importers = {
|
66
67
|
"csv": self.from_csv,
|
67
68
|
"json": self.from_json,
|
@@ -73,37 +74,36 @@ class TreeNodeImporter:
|
|
73
74
|
raise ValueError("Unsupported import format")
|
74
75
|
|
75
76
|
raw_data = importers[self.format]()
|
76
|
-
# Обработка: фильтрация полей, упаковка сложных значений и приведение типов
|
77
|
-
processed_data = self.process_records(raw_data)
|
78
|
-
return processed_data
|
79
77
|
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
78
|
+
# Processing: field filtering, complex value packing and type casting
|
79
|
+
processed = []
|
80
|
+
for record in raw_data:
|
81
|
+
filtered = self.filter_fields(record)
|
82
|
+
filtered = self.process_complex_fields(filtered)
|
83
|
+
filtered = self.cast_record_types(filtered)
|
84
|
+
processed.append(filtered)
|
84
85
|
|
85
|
-
|
86
|
-
"""Импорт из JSON."""
|
87
|
-
return json.loads(self.get_text_content())
|
86
|
+
return processed
|
88
87
|
|
89
|
-
def
|
90
|
-
"""
|
91
|
-
|
92
|
-
|
88
|
+
def get_tn_orders(self, rows):
|
89
|
+
"""Calculate the materialized path without including None parents."""
|
90
|
+
# Build a mapping from id to record for quick lookup.
|
91
|
+
row_dict = {row["id"]: row for row in rows}
|
93
92
|
|
94
|
-
|
95
|
-
|
96
|
-
|
93
|
+
def get_ancestor_path(row):
|
94
|
+
parent_field = 'tn_parent' if 'tn_parent' in row else 'tn_parent_id'
|
95
|
+
return get_ancestor_path(row_dict[row[parent_field]]) + [row["id"]] if row[parent_field] else [row["id"]]
|
97
96
|
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
97
|
+
return [
|
98
|
+
{"id": row["id"], "path": get_ancestor_path(row)}
|
99
|
+
for row in rows
|
100
|
+
]
|
102
101
|
|
103
102
|
def filter_fields(self, record):
|
104
103
|
"""
|
105
|
-
|
106
|
-
|
104
|
+
Filter the record according to the mapping.
|
105
|
+
|
106
|
+
Only the necessary keys remain, while the names are renamed.
|
107
107
|
"""
|
108
108
|
new_record = {}
|
109
109
|
for file_key, model_field in self.mapping.items():
|
@@ -112,9 +112,13 @@ class TreeNodeImporter:
|
|
112
112
|
|
113
113
|
def process_complex_fields(self, record):
|
114
114
|
"""
|
115
|
-
|
115
|
+
Pack it into a JSON string.
|
116
|
+
|
117
|
+
If the field value is a dictionary or list.
|
116
118
|
"""
|
117
119
|
for key, value in record.items():
|
120
|
+
if isinstance(value, uuid.UUID):
|
121
|
+
record[key] = str(value)
|
118
122
|
if isinstance(value, (list, dict)):
|
119
123
|
try:
|
120
124
|
record[key] = json.dumps(value, ensure_ascii=False)
|
@@ -125,13 +129,12 @@ class TreeNodeImporter:
|
|
125
129
|
|
126
130
|
def cast_record_types(self, record):
|
127
131
|
"""
|
128
|
-
|
129
|
-
|
130
|
-
Для каждого поля вызывается его метод to_python(). Если значение равно nan,
|
131
|
-
оно заменяется на None.
|
132
|
+
Cast the values of the record fields to the types defined in the model.
|
132
133
|
|
133
|
-
|
134
|
-
|
134
|
+
For each field, its to_python() method is called. If the value is nan,
|
135
|
+
it is replaced with None.
|
136
|
+
For ForeignKey fields (many-to-one), the value is written to
|
137
|
+
the <field>_id attribute, and the original key is removed.
|
135
138
|
"""
|
136
139
|
for field in self.model._meta.fields:
|
137
140
|
field_name = field.name
|
@@ -145,10 +148,15 @@ class TreeNodeImporter:
|
|
145
148
|
# Записываем в атрибут, например, tn_parent_id
|
146
149
|
record[field.attname] = converted
|
147
150
|
except Exception as e:
|
148
|
-
logger.warning(
|
149
|
-
|
151
|
+
logger.warning(
|
152
|
+
"Error converting FK field %s with value %r: %s",
|
153
|
+
field_name,
|
154
|
+
value,
|
155
|
+
e
|
156
|
+
)
|
150
157
|
record[field.attname] = None
|
151
|
-
# Удаляем оригинальное значение, чтобы Django не пыталась
|
158
|
+
# Удаляем оригинальное значение, чтобы Django не пыталась
|
159
|
+
# его обработать
|
152
160
|
del record[field_name]
|
153
161
|
else:
|
154
162
|
if field_name in record:
|
@@ -159,138 +167,163 @@ class TreeNodeImporter:
|
|
159
167
|
try:
|
160
168
|
record[field_name] = field.to_python(value)
|
161
169
|
except Exception as e:
|
162
|
-
logger.warning(
|
163
|
-
|
170
|
+
logger.warning(
|
171
|
+
"Error converting field %s with value %r: %s",
|
172
|
+
field_name,
|
173
|
+
value,
|
174
|
+
e
|
175
|
+
)
|
164
176
|
record[field_name] = None
|
165
177
|
return record
|
166
178
|
|
167
|
-
|
168
|
-
"""
|
169
|
-
Обрабатывает список записей:
|
170
|
-
1. Фильтрует поля по маппингу.
|
171
|
-
2. Упаковывает сложные (вложенные) данные в JSON.
|
172
|
-
3. Приводит значения каждого поля к типам, определённым в модели.
|
173
|
-
"""
|
174
|
-
processed = []
|
175
|
-
for record in records:
|
176
|
-
filtered = self.filter_fields(record)
|
177
|
-
filtered = self.process_complex_fields(filtered)
|
178
|
-
filtered = self.cast_record_types(filtered)
|
179
|
-
processed.append(filtered)
|
180
|
-
return processed
|
179
|
+
# ------------------------------------------------------------------------
|
181
180
|
|
182
|
-
def
|
181
|
+
def finalize(self, raw_data):
|
183
182
|
"""
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
'
|
195
|
-
|
196
|
-
|
197
|
-
|
183
|
+
Finalize import.
|
184
|
+
|
185
|
+
Processes raw_data, creating and updating objects by levels
|
186
|
+
(from roots to leaves) using the materialized path to calculate
|
187
|
+
the level.
|
188
|
+
|
189
|
+
Algorithm:
|
190
|
+
1. Build a raw_by_id dictionary for quick access to records by id.
|
191
|
+
2. For each record, calculate the materialized path:
|
192
|
+
- If tn_parent is specified and exists in raw_data, recursively get
|
193
|
+
the parent's path and add its id.
|
194
|
+
- If tn_parent is missing from raw_data, check if the parent is in
|
195
|
+
the database.
|
196
|
+
If not, generate an error.
|
197
|
+
3. Record level = length of its materialized path.
|
198
|
+
4. Split records into those that need to be created (if the object
|
199
|
+
with the given id is not yet in the database), and those that need
|
200
|
+
to be updated.
|
201
|
+
5. To create, process groups by levels (sort by increasing level):
|
202
|
+
- Validate each record, if there are no errors, add the instance to
|
203
|
+
the list.
|
204
|
+
- After each level, we perform bulk_create.
|
205
|
+
6. For updates, we collect instances, fixing fields (without id)
|
206
|
+
and perform bulk_update.
|
207
|
+
|
208
|
+
Returns a dictionary:
|
209
|
+
{
|
210
|
+
"create": [созданные объекты],
|
211
|
+
"update": [обновлённые объекты],
|
212
|
+
"errors": [список ошибок]
|
213
|
+
}
|
198
214
|
"""
|
199
215
|
result = {
|
200
216
|
"create": [],
|
201
217
|
"update": [],
|
202
|
-
"update_fields": [],
|
203
|
-
"fk_mappings": {},
|
204
218
|
"errors": []
|
205
219
|
}
|
206
220
|
|
207
|
-
for
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
if
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
221
|
+
# 1. Calculate the materialized path and level for each entry.
|
222
|
+
paths = self.get_tn_orders(raw_data)
|
223
|
+
# key: record id, value: уровень (int)
|
224
|
+
levels_by_record = {rec["id"]: len(rec["path"])-1 for rec in paths}
|
225
|
+
|
226
|
+
# 2. Разбиваем записи по уровням
|
227
|
+
levels = {}
|
228
|
+
for record in raw_data:
|
229
|
+
level = levels_by_record.get(record["id"], 0)
|
230
|
+
if level not in levels:
|
231
|
+
levels[level] = []
|
232
|
+
levels[level].append(record)
|
233
|
+
|
234
|
+
records_by_level = [
|
235
|
+
sorted(
|
236
|
+
levels[key],
|
237
|
+
key=lambda x: (x.get(
|
238
|
+
"tn_parent",
|
239
|
+
x.get("tn_parent_id", 0)) or -1)
|
240
|
+
)
|
241
|
+
for key in sorted(levels.keys())
|
242
|
+
]
|
243
|
+
|
244
|
+
# 4. We split the records into those to create and those to update.
|
245
|
+
# The list of records to update
|
246
|
+
to_update = []
|
247
|
+
|
248
|
+
for level in range(len(records_by_level)):
|
249
|
+
instances_to_create = []
|
250
|
+
for record in records_by_level[level]:
|
251
|
+
rec_id = record["id"]
|
252
|
+
if self.model.objects.filter(pk=rec_id).exists():
|
253
|
+
to_update.append(record)
|
254
|
+
else:
|
255
|
+
instance = self.model(**record)
|
256
|
+
try:
|
257
|
+
instance.full_clean()
|
258
|
+
instances_to_create.append(instance)
|
259
|
+
except Exception as e:
|
260
|
+
result["errors"].append(f"Validation error for record \
|
261
|
+
{record['id']} on level {level}: {e}")
|
262
|
+
try:
|
263
|
+
created = self.model.objects.bulk_create(instances_to_create)
|
264
|
+
result["create"].extend(created)
|
265
|
+
except Exception as e:
|
266
|
+
result["errors"].append(f"Create error on level {level}: {e}")
|
267
|
+
|
268
|
+
# 6. Processing updates: collecting instances and a list of fields
|
269
|
+
# for bulk_update
|
270
|
+
updated_instances = []
|
271
|
+
update_fields_set = set()
|
272
|
+
for record in to_update:
|
273
|
+
rec_id = record["id"]
|
226
274
|
try:
|
227
|
-
instance = self.model(
|
275
|
+
instance = self.model.objects.get(pk=rec_id)
|
276
|
+
for field, value in record.items():
|
277
|
+
if field != "id":
|
278
|
+
setattr(instance, field, value)
|
279
|
+
update_fields_set.add(field)
|
228
280
|
instance.full_clean()
|
229
|
-
|
230
|
-
# Сохраняем значение родительского ключа для последующего обновления
|
231
|
-
result["fk_mappings"][instance.id] = fk_value
|
281
|
+
updated_instances.append(instance)
|
232
282
|
except Exception as e:
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
283
|
+
result["errors"].append(
|
284
|
+
f"Validation error updating record {rec_id}: {e}")
|
285
|
+
update_fields = list(update_fields_set)
|
286
|
+
if updated_instances:
|
287
|
+
try:
|
288
|
+
self.model.objects.bulk_update(updated_instances, update_fields)
|
289
|
+
result["update"].extend(updated_instances)
|
290
|
+
except Exception as e:
|
291
|
+
result["errors"].append(f"Bulk update error: {e}")
|
237
292
|
|
238
|
-
# В данном сценарии обновление происходит только для родительской связи
|
239
|
-
result["update_fields"] = ['tn_parent']
|
240
293
|
return result
|
241
294
|
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
295
|
+
# ------------------------------------------------------------------------
|
296
|
+
|
297
|
+
def from_csv(self):
|
298
|
+
"""Import from CSV."""
|
299
|
+
text = self.get_text_content()
|
300
|
+
return list(csv.DictReader(StringIO(text)))
|
301
|
+
|
302
|
+
def from_json(self):
|
303
|
+
"""Import from JSON."""
|
304
|
+
return json.loads(self.get_text_content())
|
305
|
+
|
306
|
+
def from_xlsx(self):
|
307
|
+
"""Import from XLSX (Excel)."""
|
308
|
+
file_stream = BytesIO(self.file_content)
|
309
|
+
rows = []
|
310
|
+
wb = openpyxl.load_workbook(file_stream, read_only=True)
|
311
|
+
ws = wb.active
|
312
|
+
headers = [
|
313
|
+
cell.value for cell in next(ws.iter_rows(min_row=1, max_row=1))
|
314
|
+
]
|
315
|
+
for row in ws.iter_rows(min_row=2, values_only=True):
|
316
|
+
rows.append(dict(zip(headers, row)))
|
317
|
+
return rows
|
318
|
+
|
319
|
+
def from_yaml(self):
|
320
|
+
"""Import from YAML."""
|
321
|
+
return yaml.safe_load(self.get_text_content())
|
322
|
+
|
323
|
+
def from_tsv(self):
|
324
|
+
"""Import from TSV."""
|
325
|
+
text = self.get_text_content()
|
326
|
+
return list(csv.DictReader(StringIO(text), delimiter="\t"))
|
254
327
|
|
255
|
-
def update_parent_relations(self, fk_mappings):
|
256
|
-
"""
|
257
|
-
Обновляет поле tn_parent для объектов, используя сохранённые fk_mappings.
|
258
|
-
:param fk_mappings: словарь {id_объекта: значение родительского ключа из исходных данных}
|
259
|
-
"""
|
260
|
-
instances_to_update = []
|
261
|
-
for obj_id, parent_id in fk_mappings.items():
|
262
|
-
# Если родитель не указан, пропускаем
|
263
|
-
if not parent_id:
|
264
|
-
continue
|
265
|
-
try:
|
266
|
-
instance = self.model.objects.get(pk=obj_id)
|
267
|
-
parent_instance = self.model.objects.get(pk=parent_id)
|
268
|
-
instance.tn_parent = parent_instance
|
269
|
-
instances_to_update.append(instance)
|
270
|
-
except self.model.DoesNotExist:
|
271
|
-
logger.warning(
|
272
|
-
"Parent with id %s not found for instance %s", parent_id, obj_id)
|
273
|
-
if instances_to_update:
|
274
|
-
update_fields = ['tn_parent']
|
275
|
-
self.model.objects.bulk_update(
|
276
|
-
instances_to_update, update_fields, batch_size=1000)
|
277
|
-
|
278
|
-
# Если захочешь объединить операции сохранения и обновления родителей,
|
279
|
-
# можно добавить метод, который вызовет save_data и update_parent_relations последовательно.
|
280
|
-
def finalize_import(self, clean_result):
|
281
|
-
"""
|
282
|
-
Финализирует импорт: сохраняет новые объекты и обновляет родительские связи.
|
283
|
-
:param clean_result: словарь, возвращённый методом clean.
|
284
|
-
"""
|
285
|
-
# Если есть ошибки – можно прервать импорт или вернуть их для обработки
|
286
|
-
if clean_result["errors"]:
|
287
|
-
return clean_result["errors"]
|
288
|
-
|
289
|
-
# Сначала выполняем массовое создание
|
290
|
-
self.save_data(
|
291
|
-
clean_result["create"], clean_result["update"], clean_result["update_fields"])
|
292
|
-
# Затем обновляем родительские связи
|
293
|
-
self.update_parent_relations(clean_result["fk_mappings"])
|
294
|
-
return None # Или вернуть успешное сообщение
|
295
328
|
|
296
329
|
# The End
|
treenode/views.py
CHANGED
@@ -14,7 +14,7 @@ Features:
|
|
14
14
|
- Uses optimized QuerySets for efficient database queries.
|
15
15
|
- Handles validation and error responses gracefully.
|
16
16
|
|
17
|
-
Version: 2.0.
|
17
|
+
Version: 2.0.11
|
18
18
|
Author: Timur Kady
|
19
19
|
Email: timurkady@yandex.com
|
20
20
|
"""
|
@@ -23,8 +23,9 @@ Email: timurkady@yandex.com
|
|
23
23
|
from django.http import JsonResponse
|
24
24
|
from django.views import View
|
25
25
|
from django.apps import apps
|
26
|
-
|
26
|
+
import numpy as np
|
27
27
|
from django.core.exceptions import ObjectDoesNotExist
|
28
|
+
from django.utils.translation import gettext_lazy as _
|
28
29
|
|
29
30
|
|
30
31
|
class TreeNodeAutocompleteView(View):
|
@@ -50,14 +51,11 @@ class TreeNodeAutocompleteView(View):
|
|
50
51
|
)
|
51
52
|
|
52
53
|
queryset = model.objects.filter(name__icontains=q)
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
default=Value(len(pk_list)),
|
59
|
-
output_field=IntegerField())
|
60
|
-
)[:10]
|
54
|
+
# Sorting
|
55
|
+
tn_orders = np.array([obj.tn_order for obj in queryset])
|
56
|
+
sorted_indices = np.argsort(tn_orders)
|
57
|
+
queryset_list = list(queryset.iterator())
|
58
|
+
sorted_queryset = [queryset_list[int(idx)] for idx in sorted_indices]
|
61
59
|
|
62
60
|
results = [
|
63
61
|
{
|
@@ -66,8 +64,17 @@ class TreeNodeAutocompleteView(View):
|
|
66
64
|
"level": node.get_level(),
|
67
65
|
"is_leaf": node.is_leaf(),
|
68
66
|
}
|
69
|
-
for node in
|
67
|
+
for node in sorted_queryset
|
70
68
|
]
|
69
|
+
|
70
|
+
root_option = {
|
71
|
+
"id": "",
|
72
|
+
"text": _("Root"),
|
73
|
+
"level": 0,
|
74
|
+
"is_leaf": True,
|
75
|
+
}
|
76
|
+
results.insert(0, root_option)
|
77
|
+
|
71
78
|
return JsonResponse({"results": results})
|
72
79
|
|
73
80
|
|
@@ -93,7 +100,6 @@ class GetChildrenCountView(View):
|
|
93
100
|
try:
|
94
101
|
parent_node = model.objects.get(pk=parent_id)
|
95
102
|
children_count = parent_node.get_children_count()
|
96
|
-
print("parent_id=", parent_id, " children_count=", children_count)
|
97
103
|
except ObjectDoesNotExist:
|
98
104
|
return JsonResponse(
|
99
105
|
{"error": "Parent node not found"},
|
treenode/widgets.py
CHANGED
@@ -13,7 +13,7 @@ Features:
|
|
13
13
|
- Supports dynamic model binding for reusable implementations.
|
14
14
|
- Integrates with Django’s form system.
|
15
15
|
|
16
|
-
Version: 2.0.
|
16
|
+
Version: 2.0.11
|
17
17
|
Author: Timur Kady
|
18
18
|
Email: timurkady@yandex.com
|
19
19
|
"""
|
@@ -48,18 +48,34 @@ class TreeWidget(forms.Select):
|
|
48
48
|
if "placeholder" in attrs:
|
49
49
|
del attrs["placeholder"]
|
50
50
|
|
51
|
-
#
|
51
|
+
# Force passing `model`
|
52
52
|
if "data-forward" not in attrs:
|
53
|
-
|
53
|
+
model = getattr(self, "model", None)
|
54
|
+
if not model and hasattr(self.choices, "queryset"):
|
54
55
|
model = self.choices.queryset.model
|
56
|
+
if model is None:
|
57
|
+
raise ValueError("TreeWidget: model not passed or not defined")
|
58
|
+
|
59
|
+
try:
|
55
60
|
label = model._meta.app_label
|
56
61
|
model_name = model._meta.model_name
|
57
62
|
model_label = f"{label}.{model_name}"
|
58
63
|
attrs["data-forward"] = f'{{"model": "{model_label}"}}'
|
64
|
+
except AttributeError as e:
|
65
|
+
raise ValueError(
|
66
|
+
"TreeWidget: model object is not a valid Django model"
|
67
|
+
) from e
|
68
|
+
|
69
|
+
# Force focus to current value
|
70
|
+
if self.choices:
|
71
|
+
try:
|
72
|
+
current_value = self.value()
|
73
|
+
if current_value:
|
74
|
+
attrs["data-selected"] = str(current_value)
|
59
75
|
except Exception:
|
60
|
-
|
76
|
+
# In case the value is missing
|
77
|
+
pass
|
61
78
|
|
62
79
|
return attrs
|
63
80
|
|
64
|
-
|
65
81
|
# The End
|
File without changes
|
File without changes
|
{django_fast_treenode-2.0.9.dist-info → django_fast_treenode-2.0.11.dist-info}/top_level.txt
RENAMED
File without changes
|