libdev 0.95__tar.gz → 0.96__tar.gz

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.
Files changed (42) hide show
  1. {libdev-0.95 → libdev-0.96}/PKG-INFO +1 -1
  2. {libdev-0.95 → libdev-0.96}/libdev/__init__.py +1 -1
  3. {libdev-0.95 → libdev-0.96}/libdev/cfg.py +23 -4
  4. {libdev-0.95 → libdev-0.96}/libdev/check.py +1 -0
  5. libdev-0.96/libdev/dev.py +29 -0
  6. libdev-0.96/libdev/doc.py +29 -0
  7. {libdev-0.95 → libdev-0.96}/libdev/num.py +31 -10
  8. {libdev-0.95 → libdev-0.96}/libdev/req.py +14 -19
  9. {libdev-0.95 → libdev-0.96}/libdev/time.py +51 -12
  10. {libdev-0.95 → libdev-0.96}/libdev.egg-info/PKG-INFO +1 -1
  11. {libdev-0.95 → libdev-0.96}/tests/test_num.py +1 -0
  12. {libdev-0.95 → libdev-0.96}/tests/test_time.py +7 -0
  13. libdev-0.95/libdev/dev.py +0 -21
  14. libdev-0.95/libdev/doc.py +0 -17
  15. {libdev-0.95 → libdev-0.96}/LICENSE +0 -0
  16. {libdev-0.95 → libdev-0.96}/README.md +0 -0
  17. {libdev-0.95 → libdev-0.96}/libdev/codes.py +0 -0
  18. {libdev-0.95 → libdev-0.96}/libdev/crypt.py +0 -0
  19. {libdev-0.95 → libdev-0.96}/libdev/fin.py +0 -0
  20. {libdev-0.95 → libdev-0.96}/libdev/gen.py +0 -0
  21. {libdev-0.95 → libdev-0.96}/libdev/img.py +0 -0
  22. {libdev-0.95 → libdev-0.96}/libdev/lang.py +0 -0
  23. {libdev-0.95 → libdev-0.96}/libdev/log.py +0 -0
  24. {libdev-0.95 → libdev-0.96}/libdev/s3.py +0 -0
  25. {libdev-0.95 → libdev-0.96}/libdev.egg-info/SOURCES.txt +0 -0
  26. {libdev-0.95 → libdev-0.96}/libdev.egg-info/dependency_links.txt +0 -0
  27. {libdev-0.95 → libdev-0.96}/libdev.egg-info/requires.txt +0 -0
  28. {libdev-0.95 → libdev-0.96}/libdev.egg-info/top_level.txt +0 -0
  29. {libdev-0.95 → libdev-0.96}/setup.cfg +0 -0
  30. {libdev-0.95 → libdev-0.96}/setup.py +0 -0
  31. {libdev-0.95 → libdev-0.96}/tests/test_cfg.py +0 -0
  32. {libdev-0.95 → libdev-0.96}/tests/test_check.py +0 -0
  33. {libdev-0.95 → libdev-0.96}/tests/test_codes.py +0 -0
  34. {libdev-0.95 → libdev-0.96}/tests/test_crypt.py +0 -0
  35. {libdev-0.95 → libdev-0.96}/tests/test_dev.py +0 -0
  36. {libdev-0.95 → libdev-0.96}/tests/test_doc.py +0 -0
  37. {libdev-0.95 → libdev-0.96}/tests/test_gen.py +0 -0
  38. {libdev-0.95 → libdev-0.96}/tests/test_img.py +0 -0
  39. {libdev-0.95 → libdev-0.96}/tests/test_lang.py +0 -0
  40. {libdev-0.95 → libdev-0.96}/tests/test_log.py +0 -0
  41. {libdev-0.95 → libdev-0.96}/tests/test_req.py +0 -0
  42. {libdev-0.95 → libdev-0.96}/tests/test_s3.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: libdev
3
- Version: 0.95
3
+ Version: 0.96
4
4
  Summary: Set of standard functions for development
5
5
  Home-page: https://github.com/chilleco/lib
6
6
  Author: Alex Poloz
@@ -2,6 +2,6 @@
2
2
  Initializing the Python package
3
3
  """
4
4
 
5
- __version__ = "0.95"
5
+ __version__ = "0.96"
6
6
 
7
7
  __all__ = ("__version__",)
@@ -1,5 +1,10 @@
1
- """
2
- Functionality of getting configuration
1
+ """Centralized configuration loader for LibDev consumers.
2
+
3
+ This module mirrors the behavior documented in `LIBDEV_DOCUMENTATION.md`:
4
+ it first ingests a project level ``sets.json`` file, then overlays values
5
+ from ``.env`` (via ``python-dotenv``) by translating dotted keys to
6
+ ``UPPER_SNAKE_CASE`` environment variables. Use the helpers below instead of
7
+ calling ``os.getenv`` throughout the codebase so the hierarchy stays uniform.
3
8
  """
4
9
 
5
10
  import os
@@ -19,7 +24,15 @@ if os.path.isfile(".env"):
19
24
 
20
25
 
21
26
  def cfg(name, default=None):
22
- """Get config value by key"""
27
+ """Return a config value stored in ``sets.json``/``.env``.
28
+
29
+ The lookup walks dotted paths inside the parsed JSON structure and, when a
30
+ key is missing, falls back to an environment variable where dots are
31
+ replaced with underscores and the string is upper-cased (``api.base`` →
32
+ ``API_BASE``). Environment values are JSON-decoded automatically so booleans
33
+ and numeric strings turn into native Python types. ``default`` is returned
34
+ when a key is absent in both sources.
35
+ """
23
36
 
24
37
  keys = name.split(".")
25
38
  data = sets
@@ -44,7 +57,13 @@ def cfg(name, default=None):
44
57
 
45
58
 
46
59
  def set_cfg(name, value):
47
- """Set config value"""
60
+ """Mutate the in-memory ``sets`` dictionary for tests or overrides.
61
+
62
+ Writes scoped dotted keys back into the ``sets`` mapping without touching
63
+ disk. This mirrors the behavior in consumer repos that temporarily adjust
64
+ configuration for integration tests or AI agents. Changes live only for the
65
+ current process and should be reset between tests.
66
+ """
48
67
 
49
68
  array_name = name.split(".")
50
69
  dictionary = {}
@@ -239,4 +239,5 @@ def get_url(data: str) -> str | None:
239
239
 
240
240
 
241
241
  def clear_text(data, extra=".,"):
242
+ """Strip all characters except alphanumerics, space, and ``extra`` chars."""
242
243
  return re.sub(rf"[^\w {extra}]", "", data).strip()
@@ -0,0 +1,29 @@
1
+ """Development-environment helpers tied to LibDev guidelines.
2
+
3
+ Currently exposes public IP validation logic that mirrors the rules documented
4
+ in ``LIBDEV_DOCUMENTATION.md`` for analytics and logging pipelines. Extend this
5
+ module instead of sprinkling regex checks throughout consumer projects.
6
+ """
7
+
8
+ import re
9
+
10
+
11
+ def check_public_ip(ip):
12
+ """Return ``ip`` if it is routable on the public internet.
13
+
14
+ Private (RFC1918), loopback, and carrier-grade NAT subnets are filtered
15
+ out, ensuring only analyzable public addresses pass downstream. ``None`` is
16
+ returned for empty inputs or addresses that match reserved ranges.
17
+ """
18
+
19
+ if not ip:
20
+ return None
21
+
22
+ return (
23
+ None
24
+ if re.match(
25
+ r"^(172\.(1[6-9]\.|2[0-9]\.|3[0-1]\.)|192\.168\.|10\.|127\.)",
26
+ ip,
27
+ )
28
+ else ip
29
+ )
@@ -0,0 +1,29 @@
1
+ """Utility helpers for dealing with document/JSON serialization.
2
+
3
+ These functions are referenced by the integration guide to make sure assets
4
+ and structured logs look the same across repositories (e.g., ``log.json`` uses
5
+ ``to_json`` under the hood).
6
+ """
7
+
8
+ import base64
9
+ import json
10
+
11
+
12
+ def to_base64(image, mime="image/jpg"):
13
+ """Return a ``data:`` URL for the given file-like ``image``.
14
+
15
+ Reads the stream, base64-encodes the bytes, and prefixes it with the MIME
16
+ Type so downstream clients (frontends, bots) can embed the payload directly
17
+ without touching disk.
18
+ """
19
+ data = base64.b64encode(image.read())
20
+ return f"data:{mime};base64,{data.decode('utf-8')}"
21
+
22
+
23
+ def to_json(data):
24
+ """Serialize ``data`` to a UTF-8 friendly JSON string.
25
+
26
+ Uses tab indentation and ``ensure_ascii=False`` to preserve Cyrillic or
27
+ emoji content mentioned in ``LIBDEV_DOCUMENTATION.md``.
28
+ """
29
+ return json.dumps(data, indent="\t", ensure_ascii=False)
@@ -1,5 +1,8 @@
1
- """
2
- Numbers functionality
1
+ """Numeric normalization and presentation helpers used across LibDev.
2
+
3
+ Implements the opinionated formatting rules discussed in the integration
4
+ guide: deterministic rounding, removal of floating-point artifacts, thousands
5
+ separators, and zero-compression for compact analytical displays.
3
6
  """
4
7
 
5
8
  import re
@@ -22,7 +25,7 @@ def is_float(value: str) -> bool:
22
25
 
23
26
 
24
27
  def to_num(value) -> bool:
25
- """Convert value to int or float"""
28
+ """Convert an incoming scalar to ``int``/``float`` while preserving intent."""
26
29
 
27
30
  if value is None:
28
31
  return None
@@ -80,7 +83,11 @@ def get_whole(value):
80
83
 
81
84
 
82
85
  def simplify_value(value, decimals=4):
83
- """Get the significant part of a number"""
86
+ """Return the significant digits of ``value`` capped by ``decimals``.
87
+
88
+ Used by analytics pipelines to produce short strings that still encode the
89
+ important portion of very large or tiny numbers.
90
+ """
84
91
 
85
92
  if value is None:
86
93
  return None
@@ -126,7 +133,13 @@ def pretty(
126
133
  zeros=4,
127
134
  compress=None,
128
135
  ):
129
- """Decorate the number beautifully"""
136
+ """Format ``value`` according to LibDev UI/metrics rules.
137
+
138
+ Supports optional rounding to a target precision, manual sign prefixing,
139
+ swapping the thousands separator symbol, and compressing leading/trailing
140
+ zeros (see ``compress_zeros``). This helper is the canonical way to build
141
+ user-facing number strings.
142
+ """
130
143
 
131
144
  if value is None:
132
145
  return None
@@ -282,6 +295,8 @@ def to_step(value, step=1, side=False):
282
295
 
283
296
 
284
297
  def to_plain(value) -> str:
298
+ """Convert ``value`` to a normalized decimal string without notation."""
299
+
285
300
  if value is None:
286
301
  return None
287
302
  try:
@@ -311,11 +326,17 @@ def _round_to_decimals(x, decimals):
311
326
 
312
327
 
313
328
  def compress_zeros(x, zeros=2, round=None) -> str:
314
- """
315
- 0.000012 -> '0.0₄12'
316
- 1.000045 -> '1.0₄45'
317
- round: number of digits after the zero block (rounds).
318
- zeros: minimum count of consecutive zeros to compress (default: 2).
329
+ """Compress zero runs using the subscript notation referenced in the docs.
330
+
331
+ Examples::
332
+
333
+ 0.000012 -> "0.0₄12"
334
+ 1.000045 -> "1.0₄45"
335
+
336
+ ``round`` controls how many digits remain after the compressed block, while
337
+ ``zeros`` sets the minimum run length required before a compression occurs.
338
+ Returns a string that can be passed to ``pretty`` or directly displayed in
339
+ dashboards.
319
340
  """
320
341
 
321
342
  if x is None:
@@ -1,5 +1,9 @@
1
- """
2
- Provides an asynchronous function to fetch data from a URL using aiohttp
1
+ """Async HTTP gateway used across LibDev powered projects.
2
+
3
+ Centralizes ``aiohttp`` usage so upstream services benefit from the same request
4
+ construction (JSON vs form payloads, multipart file uploads) and response
5
+ parsing rules described in ``LIBDEV_DOCUMENTATION.md``. Always ``await`` the
6
+ helpers in this module to stay inside the async boundary.
3
7
  """
4
8
 
5
9
  import aiohttp
@@ -14,24 +18,15 @@ async def fetch(
14
18
  headers=None,
15
19
  timeout=None,
16
20
  ):
17
- """
18
- Fetch data from a URL using aiohttp.
19
-
20
- Args:
21
- url (str): The URL to fetch data from.
22
- payload (dict, optional): The payload to send with the request.
23
- Defaults to None.
24
- type_req (str, optional): The type of request (e.g., 'post', 'put',
25
- 'delete', etc.). Defaults to 'post'.
26
- type_data (str, optional): The type of data (e.g., 'json', 'data').
27
- Defaults to 'json'.
28
- headers (dict, optional): The headers to include with the request.
29
- Defaults to None.
30
- timeout (float, optional): The timeout for the request in seconds.
31
- Defaults to None.
21
+ """Perform an HTTP request and normalize the response payload.
32
22
 
33
- Returns:
34
- tuple: A tuple containing the status code and the response data.
23
+ Parameters mirror the rules from the integration guide: ``files`` may be a
24
+ mapping of field name to bytes/file-like objects (forcing multipart form
25
+ uploads), ``type_data`` controls whether the ``payload`` is supplied via the
26
+ ``json`` or ``data`` keyword, and ``type_req`` is the lowercase HTTP verb. A
27
+ status code integer and the decoded response body (JSON dict, plain text, or
28
+ raw bytes) are returned. Callers are expected to provide their own retries
29
+ or circuit breaking logic.
35
30
  """
36
31
  if payload is None:
37
32
  payload = {}
@@ -1,5 +1,8 @@
1
- """
2
- Time functionality
1
+ """Datetime parsing/formatting helpers aligned with LibDev localization rules.
2
+
3
+ The functions below implement the Russian month parsing, timezone handling, and
4
+ delta formatting conventions cited in ``LIBDEV_DOCUMENTATION.md`` so every
5
+ project surfaces dates the same way.
3
6
  """
4
7
 
5
8
  # TODO: Учитывать летнее / зимнее время в прошлых датах, которого теперь нет
@@ -51,13 +54,15 @@ def to_tz(hours):
51
54
  return datetime.timezone(datetime.timedelta(hours=hours))
52
55
 
53
56
 
54
- def get_time(data=None, template="%d.%m.%Y %H:%M:%S", tz=0):
57
+ def get_time(data=None, template="%d.%m.%Y %H:%M:%S", tz=None):
55
58
  """Get time from timestamp"""
56
59
 
57
60
  if data is None:
58
61
  data = time.time()
59
62
  if isinstance(data, str):
60
63
  return data
64
+ if tz is None:
65
+ tz = 0
61
66
 
62
67
  # TODO: smart TZ
63
68
 
@@ -67,18 +72,20 @@ def get_time(data=None, template="%d.%m.%Y %H:%M:%S", tz=0):
67
72
  return time.strftime(template, time.gmtime(data + tz * 3600))
68
73
 
69
74
 
70
- def get_date(data=None, template="%d.%m.%Y", tz=0):
75
+ def get_date(data=None, template="%d.%m.%Y", tz=None):
71
76
  """Get date from timestamp"""
72
77
  return get_time(data, template, tz)
73
78
 
74
79
 
75
- def decode_time(data=None, template="%d.%m.%Y %H:%M:%S", tz=0):
80
+ def decode_time(data=None, template="%d.%m.%Y %H:%M:%S", tz=None):
76
81
  """Get timestamp from time"""
77
82
 
78
83
  if not data:
79
84
  return None
80
85
  if isinstance(data, int):
81
86
  return data
87
+ if tz is None:
88
+ tz = 0
82
89
 
83
90
  try:
84
91
  data = datetime.datetime.strptime(data, template)
@@ -90,17 +97,20 @@ def decode_time(data=None, template="%d.%m.%Y %H:%M:%S", tz=0):
90
97
  return int(data.timestamp())
91
98
 
92
99
 
93
- def decode_date(data=None, template="%d.%m.%Y", tz=0):
100
+ def decode_date(data=None, template="%d.%m.%Y", tz=None):
94
101
  """Get timestamp from date"""
95
102
  return decode_time(data, template, tz)
96
103
 
97
104
 
98
105
  # pylint: disable=too-many-branches,too-many-statements
99
- def parse_time(data: str, tz=0):
106
+ def parse_time(data: str, tz=None):
100
107
  """Parse time"""
101
108
 
102
109
  # TODO: 16 year -> 2016 year
103
110
 
111
+ if tz is None:
112
+ tz = 0
113
+
104
114
  data = data.lower()
105
115
 
106
116
  # Cut special characters
@@ -264,7 +274,7 @@ def format_delta(sec, short=False, locale="en"):
264
274
  return delta
265
275
 
266
276
 
267
- def get_midnight(timestamp=None, tz=0):
277
+ def get_midnight(timestamp=None, tz=None):
268
278
  """
269
279
  Get the start of the day (midnight) for a given timestamp in a specified timezone.
270
280
 
@@ -275,14 +285,19 @@ def get_midnight(timestamp=None, tz=0):
275
285
  Returns:
276
286
  float: The timestamp for the start of the day (midnight) in the specified timezone.
277
287
  """
288
+
278
289
  if timestamp is None:
279
290
  timestamp = time.time()
291
+ if tz is None:
292
+ tz = 0
293
+
280
294
  dt_local = datetime.datetime.fromtimestamp(timestamp, tz=to_tz(tz))
281
295
  start_day = dt_local.replace(hour=0, minute=0, second=0, microsecond=0)
296
+
282
297
  return int(start_day.timestamp())
283
298
 
284
299
 
285
- def get_month_start(timestamp=None, tz=0):
300
+ def get_month_start(timestamp=None, tz=None):
286
301
  """
287
302
  Get the start of the month (midnight on the first day of the month) for a given timestamp in a specified timezone.
288
303
 
@@ -293,14 +308,25 @@ def get_month_start(timestamp=None, tz=0):
293
308
  Returns:
294
309
  float: The timestamp for the start of the month in the specified timezone.
295
310
  """
311
+
296
312
  if timestamp is None:
297
313
  timestamp = time.time()
314
+ if tz is None:
315
+ tz = 0
316
+
298
317
  dt_local = datetime.datetime.fromtimestamp(timestamp, tz=to_tz(tz))
299
318
  start_month = dt_local.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
319
+
300
320
  return int(start_month.timestamp())
301
321
 
302
322
 
303
- def get_week_start(timestamp=None, tz=0):
323
+ def get_previous_month(timestamp=None, tz=None):
324
+ current_period = get_month_start(timestamp, tz)
325
+ one_month_ago = get_month_start(current_period - 1, tz)
326
+ return one_month_ago
327
+
328
+
329
+ def get_week_start(timestamp=None, tz=None):
304
330
  """
305
331
  Get the start of the week (midnight on Monday) for a given timestamp in a specified timezone.
306
332
 
@@ -311,17 +337,23 @@ def get_week_start(timestamp=None, tz=0):
311
337
  Returns:
312
338
  float: The timestamp for the start of the week (Monday at midnight) in the specified timezone.
313
339
  """
340
+
314
341
  if timestamp is None:
315
342
  timestamp = time.time()
343
+ if tz is None:
344
+ tz = 0
345
+
316
346
  dt_local = datetime.datetime.fromtimestamp(timestamp, tz=to_tz(tz))
317
347
  # Calculate days to subtract to get to Monday (weekday() returns 0 for Monday, 6 for Sunday)
318
348
  days_since_monday = dt_local.weekday()
349
+
319
350
  start_week = dt_local - datetime.timedelta(days=days_since_monday)
320
351
  start_week = start_week.replace(hour=0, minute=0, second=0, microsecond=0)
352
+
321
353
  return int(start_week.timestamp())
322
354
 
323
355
 
324
- def get_next_day(timestamp=None, tz=0):
356
+ def get_next_day(timestamp=None, tz=None):
325
357
  """
326
358
  Get the start of the next day (midnight) for a given timestamp in a specified timezone.
327
359
 
@@ -332,17 +364,22 @@ def get_next_day(timestamp=None, tz=0):
332
364
  Returns:
333
365
  float: The timestamp for the start of the next day (midnight) in the specified timezone.
334
366
  """
367
+
335
368
  if timestamp is None:
336
369
  timestamp = time.time()
370
+ if tz is None:
371
+ tz = 0
372
+
337
373
  dt_local = datetime.datetime.fromtimestamp(timestamp, tz=to_tz(tz))
338
374
  next_day = (dt_local + datetime.timedelta(days=1)).replace(
339
375
  hour=0, minute=0, second=0, microsecond=0
340
376
  )
377
+
341
378
  return int(next_day.timestamp())
342
379
 
343
380
 
344
381
  # TODO: get previous month (params=-1 +1)
345
- def get_next_month(timestamp=None, tz=0):
382
+ def get_next_month(timestamp=None, tz=None):
346
383
  """
347
384
  Get the start of the next month (midnight on the first day of the next month) for a given timestamp in a specified timezone.
348
385
 
@@ -356,6 +393,8 @@ def get_next_month(timestamp=None, tz=0):
356
393
 
357
394
  if timestamp is None:
358
395
  timestamp = time.time()
396
+ if tz is None:
397
+ tz = 0
359
398
 
360
399
  dt_local = datetime.datetime.fromtimestamp(timestamp, tz=to_tz(tz))
361
400
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: libdev
3
- Version: 0.95
3
+ Version: 0.96
4
4
  Summary: Set of standard functions for development
5
5
  Home-page: https://github.com/chilleco/lib
6
6
  Author: Alex Poloz
@@ -157,6 +157,7 @@ def test_pretty():
157
157
  assert pretty(123.456, 1) == "123"
158
158
  assert pretty(123.456, 1, True) == "+123"
159
159
  assert pretty(12345.6, 3, True) == "+12’346"
160
+ assert pretty(1046012.4859999998, 0) == "1’046’012"
160
161
  assert pretty(-0.000000235235, zeros=None, compress=None) == "-0.000000235235"
161
162
  assert pretty(-0.000000235235, zeros=4, compress=2) == "-0.0₆24"
162
163
 
@@ -13,6 +13,7 @@ from libdev.time import (
13
13
  format_delta,
14
14
  get_midnight,
15
15
  get_month_start,
16
+ get_previous_month,
16
17
  get_next_day,
17
18
  get_next_month,
18
19
  get_delta_days,
@@ -121,6 +122,12 @@ def test_get_month_start():
121
122
  assert get_month_start(1704060061, tz=3) == 1704056400
122
123
 
123
124
 
125
+ def test_get_previous_month():
126
+ assert get_previous_month() == 1759276800
127
+ assert get_previous_month(tz=3) == 1759266000
128
+ assert get_previous_month(1760648400, 1) == 1756681200
129
+
130
+
124
131
  def test_get_next_day():
125
132
  assert get_next_day(1704060061) == 1704067200
126
133
  assert get_next_day(1704060061, tz=3) == 1704142800
libdev-0.95/libdev/dev.py DELETED
@@ -1,21 +0,0 @@
1
- """
2
- Development tools
3
- """
4
-
5
- import re
6
-
7
-
8
- def check_public_ip(ip):
9
- """Check if the IP address is public"""
10
-
11
- if not ip:
12
- return None
13
-
14
- return (
15
- None
16
- if re.match(
17
- r"^(172\.(1[6-9]\.|2[0-9]\.|3[0-1]\.)|192\.168\.|10\.|127\.)",
18
- ip,
19
- )
20
- else ip
21
- )
libdev-0.95/libdev/doc.py DELETED
@@ -1,17 +0,0 @@
1
- """
2
- Documents processing functionality
3
- """
4
-
5
- import base64
6
- import json
7
-
8
-
9
- def to_base64(image, mime="image/jpg"):
10
- """Convert image to base64"""
11
- data = base64.b64encode(image.read())
12
- return f"data:{mime};base64,{data.decode('utf-8')}"
13
-
14
-
15
- def to_json(data):
16
- """Convert object to json"""
17
- return json.dumps(data, indent="\t", ensure_ascii=False)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes