Odooly 2.6.2__tar.gz → 2.6.4__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 (30) hide show
  1. {odooly-2.6.2 → odooly-2.6.4}/CHANGES.rst +18 -0
  2. {odooly-2.6.2 → odooly-2.6.4/Odooly.egg-info}/PKG-INFO +4 -7
  3. {odooly-2.6.2/Odooly.egg-info → odooly-2.6.4}/PKG-INFO +4 -7
  4. {odooly-2.6.2 → odooly-2.6.4}/README.rst +3 -6
  5. {odooly-2.6.2 → odooly-2.6.4}/odooly.py +35 -37
  6. odooly-2.6.4/odooly_run.py +165 -0
  7. odooly-2.6.2/odooly_run.py +0 -131
  8. {odooly-2.6.2 → odooly-2.6.4}/LICENSE +0 -0
  9. {odooly-2.6.2 → odooly-2.6.4}/MANIFEST.in +0 -0
  10. {odooly-2.6.2 → odooly-2.6.4}/Odooly.egg-info/SOURCES.txt +0 -0
  11. {odooly-2.6.2 → odooly-2.6.4}/Odooly.egg-info/dependency_links.txt +0 -0
  12. {odooly-2.6.2 → odooly-2.6.4}/Odooly.egg-info/entry_points.txt +0 -0
  13. {odooly-2.6.2 → odooly-2.6.4}/Odooly.egg-info/top_level.txt +0 -0
  14. {odooly-2.6.2 → odooly-2.6.4}/docs/Makefile +0 -0
  15. {odooly-2.6.2 → odooly-2.6.4}/docs/api.rst +0 -0
  16. {odooly-2.6.2 → odooly-2.6.4}/docs/conf.py +0 -0
  17. {odooly-2.6.2 → odooly-2.6.4}/docs/developer.rst +0 -0
  18. {odooly-2.6.2 → odooly-2.6.4}/docs/index.rst +0 -0
  19. {odooly-2.6.2 → odooly-2.6.4}/docs/intro.rst +0 -0
  20. {odooly-2.6.2 → odooly-2.6.4}/docs/make.bat +0 -0
  21. {odooly-2.6.2 → odooly-2.6.4}/docs/tutorial.rst +0 -0
  22. {odooly-2.6.2 → odooly-2.6.4}/odooly.ini +0 -0
  23. {odooly-2.6.2 → odooly-2.6.4}/setup.cfg +0 -0
  24. {odooly-2.6.2 → odooly-2.6.4}/setup.py +0 -0
  25. {odooly-2.6.2 → odooly-2.6.4}/tests/__init__.py +0 -0
  26. {odooly-2.6.2 → odooly-2.6.4}/tests/_common.py +0 -0
  27. {odooly-2.6.2 → odooly-2.6.4}/tests/test_client.py +0 -0
  28. {odooly-2.6.2 → odooly-2.6.4}/tests/test_interact.py +0 -0
  29. {odooly-2.6.2 → odooly-2.6.4}/tests/test_model.py +0 -0
  30. {odooly-2.6.2 → odooly-2.6.4}/tests/test_util.py +0 -0
@@ -2,6 +2,24 @@ Changelog
2
2
  ---------
3
3
 
4
4
 
5
+ 2.6.4 (2026-03-26)
6
+ ~~~~~~~~~~~~~~~~~~
7
+
8
+ * Remove ``self`` from vars available in interactive mode.
9
+ It was giving wrong suggestions in case of typing error.
10
+
11
+ * Inject ``clear`` command in built-ins for Python <= 3.12.
12
+
13
+ * Adjust ``call_kw`` path to ``/web/dataset/call_kw/<model>/<method``
14
+ for adequate HTTP logging, and to be consistent with Odoo Web.
15
+
16
+
17
+ 2.6.3 (2026-03-18)
18
+ ~~~~~~~~~~~~~~~~~~
19
+
20
+ * Improve output coloring. Do not color Python keywords and built-ins.
21
+
22
+
5
23
  2.6.2 (2026-03-17)
6
24
  ~~~~~~~~~~~~~~~~~~
7
25
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Odooly
3
- Version: 2.6.2
3
+ Version: 2.6.4
4
4
  Summary: Versatile tool for Odoo
5
5
  Home-page: http://odooly.readthedocs.org/
6
6
  Author: Florent Xicluna
@@ -46,11 +46,8 @@ Overview
46
46
  --------
47
47
 
48
48
  Odooly connects to an Odoo instance through HTTP. It makes it easy to browse the application model,
49
- and to perform actions. It carries three modes of use:
50
-
51
- (1) with command line arguments
52
- (2) as an interactive shell
53
- (3) as a client library
49
+ and to perform actions on the server. It is both an interactive shell (with pretty-printing and colors)
50
+ and a client library.
54
51
 
55
52
 
56
53
  Key features:
@@ -58,7 +55,7 @@ Key features:
58
55
  - provides an API similar to Odoo Model, through Webclient API
59
56
  - supports JSON-2 API with Odoo 19 and more recent
60
57
  - supports external API JSON-RPC as alternative
61
- - compatible with Odoo 9 to 19
58
+ - compatible with Odoo 9 to 20
62
59
  - single file ``odooly.py``, no external dependency
63
60
  - helpers for ``search``, for data model introspection, etc...
64
61
  - simplified syntax for search ``domain``
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Odooly
3
- Version: 2.6.2
3
+ Version: 2.6.4
4
4
  Summary: Versatile tool for Odoo
5
5
  Home-page: http://odooly.readthedocs.org/
6
6
  Author: Florent Xicluna
@@ -46,11 +46,8 @@ Overview
46
46
  --------
47
47
 
48
48
  Odooly connects to an Odoo instance through HTTP. It makes it easy to browse the application model,
49
- and to perform actions. It carries three modes of use:
50
-
51
- (1) with command line arguments
52
- (2) as an interactive shell
53
- (3) as a client library
49
+ and to perform actions on the server. It is both an interactive shell (with pretty-printing and colors)
50
+ and a client library.
54
51
 
55
52
 
56
53
  Key features:
@@ -58,7 +55,7 @@ Key features:
58
55
  - provides an API similar to Odoo Model, through Webclient API
59
56
  - supports JSON-2 API with Odoo 19 and more recent
60
57
  - supports external API JSON-RPC as alternative
61
- - compatible with Odoo 9 to 19
58
+ - compatible with Odoo 9 to 20
62
59
  - single file ``odooly.py``, no external dependency
63
60
  - helpers for ``search``, for data model introspection, etc...
64
61
  - simplified syntax for search ``domain``
@@ -17,11 +17,8 @@ Overview
17
17
  --------
18
18
 
19
19
  Odooly connects to an Odoo instance through HTTP. It makes it easy to browse the application model,
20
- and to perform actions. It carries three modes of use:
21
-
22
- (1) with command line arguments
23
- (2) as an interactive shell
24
- (3) as a client library
20
+ and to perform actions on the server. It is both an interactive shell (with pretty-printing and colors)
21
+ and a client library.
25
22
 
26
23
 
27
24
  Key features:
@@ -29,7 +26,7 @@ Key features:
29
26
  - provides an API similar to Odoo Model, through Webclient API
30
27
  - supports JSON-2 API with Odoo 19 and more recent
31
28
  - supports external API JSON-RPC as alternative
32
- - compatible with Odoo 9 to 19
29
+ - compatible with Odoo 9 to 20
33
30
  - single file ``odooly.py``, no external dependency
34
31
  - helpers for ``search``, for data model introspection, etc...
35
32
  - simplified syntax for search ``domain``
@@ -28,7 +28,7 @@ try:
28
28
  except ImportError:
29
29
  requests = None
30
30
 
31
- __version__ = '2.6.2'
31
+ __version__ = '2.6.4'
32
32
  __all__ = ['Client', 'Env', 'HTTPSession', 'WebAPI', 'Service', 'Json2',
33
33
  'Printer', 'Error', 'ServerError',
34
34
  'BaseModel', 'Model', 'BaseRecord', 'Record', 'RecordList',
@@ -132,7 +132,7 @@ for query in sql_queries:
132
132
  log(str({'queries': sql_queries, 'result': result}))
133
133
  result[:] = []
134
134
  """
135
- colorize = color_bold = color_comment = str
135
+ color_bold = color_comment = color_py = color_repr = str
136
136
  http_context = None
137
137
 
138
138
  if os.getenv('ODOOLY_SSL_UNVERIFIED'):
@@ -225,12 +225,12 @@ def _memoize(inst, attr, value, doc_values=None):
225
225
 
226
226
  # Simplified ast.literal_eval which does not parse operators
227
227
  def _convert(node):
228
- if isinstance(node, _ast.Constant):
228
+ if isinstance(node, _ast.Constant) and node.value.__class__ is not complex:
229
229
  return node.value
230
230
  if isinstance(node, _ast.Tuple):
231
231
  return tuple(map(_convert, node.elts))
232
232
  if isinstance(node, _ast.List):
233
- return list(map(_convert, node.elts))
233
+ return [*map(_convert, node.elts)]
234
234
  if isinstance(node, _ast.Dict):
235
235
  return {_convert(k): _convert(v)
236
236
  for (k, v) in zip(node.keys, node.values)}
@@ -242,10 +242,8 @@ def _convert(node):
242
242
  raise ValueError('malformed or disallowed expression')
243
243
 
244
244
 
245
- def literal_eval(expression, _octal_digits=frozenset('01234567')):
245
+ def literal_eval(expression):
246
246
  node = compile(expression, '<unknown>', 'eval', _ast.PyCF_ONLY_AST)
247
- if expression[:1] == '0' and expression[1:2] in _octal_digits:
248
- raise SyntaxError('unsupported octal notation')
249
247
  return _convert(node.body)
250
248
 
251
249
 
@@ -419,7 +417,7 @@ class Printer:
419
417
  xch = message[:self.cols - len(suffix)] + suffix
420
418
  print(color_comment(f"{_prefix} {xch}"))
421
419
 
422
- print_sent = functools.partialmethod(_print_, _prefix='-->')
420
+ print_sent = functools.partialmethod(_print_, _prefix=' >')
423
421
  print_recv = functools.partialmethod(_print_, _prefix='<--')
424
422
 
425
423
  def __bool__(self):
@@ -452,7 +450,7 @@ class WebAPI:
452
450
  self._dispatch = client._proxy_web(endpoint)
453
451
  self._server = urljoin(client._server, '/')
454
452
  self._endpoint = f'/{endpoint}'
455
- self._methods = methods
453
+ self._methods = [*methods]
456
454
  self._printer = client._printer
457
455
 
458
456
  def __repr__(self):
@@ -802,7 +800,7 @@ class Env:
802
800
 
803
801
  def refresh(self):
804
802
  db_key, preserve = (self.db_name, self.client._server), ('auth', Env)
805
- for key in list(self._cache):
803
+ for key in [*self._cache]:
806
804
  if key[1:] == db_key and key[0] not in preserve and self._cache[key] != self:
807
805
  del self._cache[key]
808
806
  self._access_models = None
@@ -884,7 +882,7 @@ class Env:
884
882
  method, [ids] = 'search_read', searchargs(params[:1])
885
883
  else:
886
884
  order_ids = kwargs.pop('order', False) and params[0]
887
- ids = sorted(set(params[0]) - {False})
885
+ ids = sorted({*params[0]} - {False})
888
886
  if not ids and order_ids:
889
887
  return [False] * len(order_ids)
890
888
  if not ids:
@@ -1091,7 +1089,7 @@ class Env:
1091
1089
  if not self.db_name and (not self.uid or login == self.user.login) and self.session_info.get('db'):
1092
1090
  empty_db_key = (self.db_name, self.client._server)
1093
1091
  self.db_name = self.session_info['db']
1094
- for key in list(self._cache):
1092
+ for key in [*self._cache]:
1095
1093
  if key[1:] == empty_db_key:
1096
1094
  self._cache_set(key[0], self._cache[key])
1097
1095
  print(f'Session authenticated for {login!r}' if self.session_info['uid'] else 'Failed')
@@ -1278,6 +1276,8 @@ class Client:
1278
1276
  return self._post_jsonrpc(f"{name}/{method}", params=params)
1279
1277
  else:
1280
1278
  def dispatch_web(method, params):
1279
+ if method == 'call_kw' and name == 'web/dataset':
1280
+ method = f"{method}/{params['model']}/{params['method']}"
1281
1281
  return self._post_jsonrpc(f"{name}/{method}", params=params)
1282
1282
  return dispatch_web
1283
1283
 
@@ -1306,7 +1306,7 @@ class Client:
1306
1306
  try:
1307
1307
  cls._saved_config[environment] = read_config(environment)
1308
1308
  except Exception:
1309
- envs = list(cls._saved_config)
1309
+ envs = [*cls._saved_config]
1310
1310
  if cls._config_file.exists():
1311
1311
  envs += [env for env in read_config() if env not in cls._saved_config]
1312
1312
  raise Error("No such environment.\nAvailable: " + ", ".join(envs))
@@ -1467,7 +1467,6 @@ class Client:
1467
1467
  assert not user, f"Use client.login({user!r}) instead"
1468
1468
  self._globals['client'] = self.env.client
1469
1469
  self._globals['env'] = env = self.env
1470
- self._globals['self'] = env.user if env.uid else None
1471
1470
  self._set_prompt()
1472
1471
  # Logged in?
1473
1472
  if env.uid:
@@ -1480,7 +1479,7 @@ class Client:
1480
1479
 
1481
1480
  @classmethod
1482
1481
  def _set_interactive(cls, global_vars={}):
1483
- global colorize, color_bold, color_comment
1482
+ global color_bold, color_comment, color_py, color_repr
1484
1483
  # Don't call multiple times
1485
1484
  del Client._set_interactive
1486
1485
  assert not cls._is_interactive()
@@ -1488,11 +1487,11 @@ class Client:
1488
1487
  global_vars[name] = globals()[name]
1489
1488
  try: # Python >= 3.14
1490
1489
  from _pyrepl.utils import disp_str, gen_colors, _colorize
1491
- colorize = lambda v: "".join(disp_str(v, colors=[*gen_colors(v)])[0])
1492
- colorize.__name__ = colorize.__qualname__ = 'colorize'
1493
- color_bold = colorize('def _').split()[1].replace('_', '{}').format
1494
- color_comment = colorize('#').replace('#', '{}').format
1495
- global_vars |= {'colorize': colorize, 'decolor': _colorize.decolor}
1490
+ color_py = color_repr = lambda v: "".join(disp_str(v, colors=[*gen_colors(v)])[0])
1491
+ color_py.__name__ = color_py.__qualname__ = 'color_python'
1492
+ color_bold = color_py('def _').split()[1].replace('_', '{}').format
1493
+ color_comment = color_py('#').replace('#', '{}').format
1494
+ global_vars |= {'color_py': color_py, 'decolor': _colorize.decolor}
1496
1495
  except ImportError:
1497
1496
  pass
1498
1497
  cls._globals = global_vars
@@ -1512,7 +1511,6 @@ class Client:
1512
1511
  and no country is set into the database.
1513
1512
  Login if successful.
1514
1513
  """
1515
- extra = (login, country_code) if login != ADMIN_USER or country_code else ()
1516
1514
  self.database.create(master_pwd=passwd, name=database, lang=lang,
1517
1515
  password=user_password, demo=demo, login=login,
1518
1516
  country_code=country_code, phone='')
@@ -1524,10 +1522,9 @@ class Client:
1524
1522
  The superadmin `passwd` and `database` are mandatory.
1525
1523
  Login if successful.
1526
1524
  """
1527
- extra = (neutralize_database,) if neutralize_database else ()
1525
+ extra = {"neutralize_database": neutralize_database} if neutralize_database else {}
1528
1526
  if extra and self.version_info < 16.0:
1529
1527
  raise Error("Argument 'neutralize_database' is not supported")
1530
- extra = {"neutralize_database": extra[0]} if extra else {}
1531
1528
  self.database.duplicate(master_pwd=passwd, name=self.env.db_name,
1532
1529
  new_name=database, **extra)
1533
1530
  # Copy the cache for authentication
@@ -1634,7 +1631,7 @@ class Model(BaseModel):
1634
1631
  methods = doc_dict.get('methods') or {}
1635
1632
  result = []
1636
1633
  for key, vals in methods.items():
1637
- arg_names = list(vals['parameters'])
1634
+ arg_names = [*vals['parameters']]
1638
1635
  if 'model' not in vals.get('api', ()):
1639
1636
  arg_names.insert(0, 'ids')
1640
1637
  result.append((key, arg_names))
@@ -1913,14 +1910,14 @@ class BaseRecord(BaseModel):
1913
1910
 
1914
1911
  def __sub__(self, other):
1915
1912
  self._check_model(other, '-')
1916
- other_ids = set(other.ids)
1913
+ other_ids = {*other.ids}
1917
1914
  ids = [idn for (id_, idn) in zip(self.ids, self._idnames)
1918
1915
  if id_ not in other_ids]
1919
1916
  return RecordList(self._model, ids)
1920
1917
 
1921
1918
  def __and__(self, other):
1922
1919
  self._check_model(other, '&')
1923
- other_ids = set(other.ids)
1920
+ other_ids = {*other.ids}
1924
1921
  self_set = self.union()
1925
1922
  ids = [idn for (id_, idn) in zip(self_set.ids, self_set._idnames)
1926
1923
  if id_ in other_ids]
@@ -1938,19 +1935,19 @@ class BaseRecord(BaseModel):
1938
1935
 
1939
1936
  def __lt__(self, other):
1940
1937
  self._check_model(other, '<')
1941
- return set(self.ids) < set(other.ids)
1938
+ return {*self.ids} < {*other.ids}
1942
1939
 
1943
1940
  def __le__(self, other):
1944
1941
  self._check_model(other, '<=')
1945
- return set(self.ids).issubset(other.ids)
1942
+ return {*self.ids}.issubset(other.ids)
1946
1943
 
1947
1944
  def __gt__(self, other):
1948
1945
  self._check_model(other, '>')
1949
- return set(self.ids) > set(other.ids)
1946
+ return {*self.ids} > {*other.ids}
1950
1947
 
1951
1948
  def __ge__(self, other):
1952
1949
  self._check_model(other, '>=')
1953
- return set(self.ids).issuperset(other.ids)
1950
+ return {*self.ids}.issuperset(other.ids)
1954
1951
 
1955
1952
  def __int__(self):
1956
1953
  return int(self.ensure_one().id)
@@ -2008,7 +2005,7 @@ class BaseRecord(BaseModel):
2008
2005
  raise TypeError(f"Mixing apples and oranges: {self} {oper} {other}")
2009
2006
 
2010
2007
  def _concat_ids(self, args):
2011
- ids = list(self._idnames)
2008
+ ids = [*self._idnames]
2012
2009
  for other in args:
2013
2010
  self._check_model(other, '+')
2014
2011
  ids.extend(other._idnames)
@@ -2026,7 +2023,7 @@ class BaseRecord(BaseModel):
2026
2023
  """
2027
2024
  ids = self._concat_ids(args)
2028
2025
  if len(ids) > 1:
2029
- seen = set()
2026
+ seen = {*()}
2030
2027
  uniqids = []
2031
2028
  for idn in ids:
2032
2029
  id_, name = idn if isinstance(idn, (list, tuple)) else (idn, None)
@@ -2269,7 +2266,7 @@ class Record(BaseRecord):
2269
2266
  else:
2270
2267
  idnames = [(arg, name)] = [arg]
2271
2268
  Ids, Id1 = self.env._class_ids
2272
- attrs = {'id': Id1(arg), 'ids': Ids([arg]), '_idnames': idnames, '_cached_keys': set()}
2269
+ attrs = {'id': Id1(arg), 'ids': Ids([arg]), '_idnames': idnames, '_cached_keys': {*()}}
2273
2270
  if name is not None:
2274
2271
  attrs['_Record__name'] = attrs['display_name'] = name
2275
2272
  self.__dict__.update(attrs)
@@ -2335,7 +2332,7 @@ class Record(BaseRecord):
2335
2332
  exist, only one of them is returned (randomly).
2336
2333
  """
2337
2334
  xml_ids = self._model._get_external_ids(self.ids)
2338
- return list(xml_ids)[0] if xml_ids else False
2335
+ return [*xml_ids][0] if xml_ids else False
2339
2336
 
2340
2337
  def _set_external_id(self, xml_id):
2341
2338
  """Set the External ID of this record."""
@@ -2390,6 +2387,7 @@ def _interact(global_vars, use_pprint=True, usage=USAGE):
2390
2387
  from _pyrepl import readline
2391
2388
  else:
2392
2389
  from code import InteractiveConsole as Console
2390
+ builtins.clear = type('', (), {'__repr__': lambda s: os.system('clear') or ''})()
2393
2391
  try:
2394
2392
  import readline
2395
2393
  import rlcompleter
@@ -2407,7 +2405,7 @@ def _interact(global_vars, use_pprint=True, usage=USAGE):
2407
2405
  pass # IOError if file missing, or other error
2408
2406
 
2409
2407
  if use_pprint:
2410
- pp = lambda obj: print(colorize(pprint.pformat(obj, **PP_FORMAT)))
2408
+ pp = lambda obj: print(color_repr(pprint.pformat(obj, **PP_FORMAT)))
2411
2409
 
2412
2410
  def displayhook(value, _printer=pp, _builtins=builtins):
2413
2411
  # Pretty-format the output
@@ -2419,7 +2417,7 @@ def _interact(global_vars, use_pprint=True, usage=USAGE):
2419
2417
  def excepthook(exc_type, exc, tb):
2420
2418
  # Print readable errors
2421
2419
  msg = ''.join(format_exception(exc_type, exc, tb, chain=False, **exfmt))
2422
- print(colorize(msg.rstrip()))
2420
+ print(color_repr(msg.rstrip()))
2423
2421
  sys.excepthook = excepthook
2424
2422
 
2425
2423
  builtins.usage = type('Usage', (), {'__call__': lambda s: print(usage),
@@ -2484,7 +2482,7 @@ def main(interact=_interact):
2484
2482
  return
2485
2483
 
2486
2484
  global_vars = Client._set_interactive()
2487
- print(colorize(USAGE))
2485
+ print(color_repr(USAGE))
2488
2486
 
2489
2487
  connect_client(args)
2490
2488
  return interact(global_vars) if interact else global_vars
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env python
2
+ """Connect to an Odoo server."""
3
+ # Colorize command-line output
4
+ import builtins
5
+ import keyword
6
+ import io
7
+ import os
8
+ import re
9
+ import token as T
10
+ import tokenize
11
+ import unicodedata
12
+
13
+ import odooly
14
+
15
+ BUILTINS = {name for name in dir(builtins) if name[:1] != '_'}
16
+ T_STRING = {getattr(T, tk) for tk in dir(T) if 'STRING' in tk}
17
+ T_MIDDLE = {getattr(T, tk) for tk in dir(T) if 'STRING_MIDDLE' in tk}
18
+ KEYWORDS = {*keyword.kwlist}
19
+ SOFTKEYW = {*getattr(keyword, 'softkwlist', ())}
20
+ J_SPECIAL = {'false', 'null', 'true'}
21
+ CONSTANTS = {'False', 'None', 'True'}
22
+ DEF_CLASS = {'def', 'class'}
23
+ CSI = '\233' # Control Sequence Introducer, same as ESC+[ or \x1b[
24
+
25
+
26
+ class THEME: # Default theme
27
+ keyword = f'{CSI}1;34m'
28
+ builtin = f'{CSI}36m'
29
+ comment = f'{CSI}31m'
30
+ string = f'{CSI}32m'
31
+ number = f'{CSI}33m'
32
+ op = f'{CSI}m' # No color
33
+ definition = f'{CSI}1m'
34
+ reset = f'{CSI}m'
35
+
36
+
37
+ def gen_colors(value, theme, keywords=KEYWORDS):
38
+ """Generate color tuples (start, end, color)."""
39
+ sio = io.StringIO(value)
40
+ line_offsets = [..., 0] + [sio.tell() for __ in sio]
41
+ sio.seek(0)
42
+
43
+ bracket_level = is_def_name = 0
44
+ yield None, 0, theme.reset
45
+ try:
46
+ for token in tokenize.generate_tokens(sio.readline):
47
+ if token.start == token.end:
48
+ continue
49
+ color, end_offset = None, 0
50
+ if token.type in T_STRING:
51
+ if token.type in T_MIDDLE and token.string[-1:] in '{}':
52
+ end_offset += 1
53
+ color = theme.string
54
+ elif token.type == T.COMMENT:
55
+ color = theme.comment
56
+ elif token.type == T.NUMBER:
57
+ color = theme.number
58
+ elif token.type == T.OP:
59
+ if token.string in '([{':
60
+ bracket_level += 1
61
+ elif token.string in ')]}':
62
+ bracket_level -= 1
63
+ color = theme.op
64
+ elif token.type == T.NAME:
65
+ if token.string in keywords:
66
+ is_def_name = token.string in DEF_CLASS
67
+ color = theme.keyword
68
+ elif keywords is not KEYWORDS:
69
+ continue
70
+ elif is_def_name:
71
+ is_def_name = False
72
+ color = theme.definition
73
+ elif token.string in SOFTKEYW and not bracket_level:
74
+ color = theme.keyword # soft_keyword
75
+ elif token.string in BUILTINS:
76
+ color = theme.builtin
77
+ if color is not None:
78
+ start = line_offsets[token.start[0]] + token.start[1]
79
+ end = line_offsets[token.end[0]] + token.end[1] + end_offset
80
+ yield start, end, color
81
+ except (SyntaxError, tokenize.TokenError):
82
+ pass
83
+ yield None, None, theme.reset
84
+
85
+
86
+ def _apply_colors(value, theme, keywords):
87
+ output = ''
88
+ if value:
89
+ __, end, c_reset = next(colors := gen_colors(value, theme=theme, keywords=keywords))
90
+
91
+ while (last_pos := end) is not None:
92
+ start, end, color = next(colors)
93
+ if start is None:
94
+ output += _escape(value[last_pos:])
95
+ else:
96
+ output += _escape(value[last_pos:start]) + color + _escape(value[start:end]) + c_reset
97
+ return output
98
+
99
+
100
+ def _escape(value):
101
+ if value.isascii():
102
+ return value
103
+ result = ''
104
+ for char in value:
105
+ if char > '\x7f' and unicodedata.category(char)[:1] == 'C':
106
+ char = rf'\u{ord(char):04x}'
107
+ result += char
108
+ return result
109
+
110
+
111
+ def color_python(value):
112
+ """Colorize Python syntax."""
113
+ return _apply_colors(value, THEME(), KEYWORDS)
114
+
115
+
116
+ def color_repr(value):
117
+ """Colorize string representation."""
118
+ return _apply_colors(value, THEME(), CONSTANTS)
119
+
120
+
121
+ def color_json(value):
122
+ """Colorize JSON representation."""
123
+ return _apply_colors(value, THEME(), J_SPECIAL)
124
+
125
+
126
+ def patch_colors(module):
127
+ """Set functions to color output."""
128
+ global THEME
129
+ try: # Python >= 3.14
130
+ from _pyrepl.utils import BUILTINS, THEME
131
+ BUILTINS |= {'Client', 'client', 'env', 'clear'}
132
+ except ImportError:
133
+ pass
134
+
135
+ module.color_repr = color_repr
136
+ decolor = odooly.partial(re.compile(r'(\233|\033\[)[;\d]*m').sub, '')
137
+ if module.color_py is not str: # Python >= 3.14
138
+ return {'color_repr': color_repr, 'decolor': decolor}
139
+ theme = THEME()
140
+ module.color_py = color_python
141
+ module.color_bold = f'{theme.definition}{{}}{theme.reset}'.format
142
+ module.color_comment = f'{theme.comment}{{}}{theme.reset}'.format
143
+ return {'color_py': color_python, 'color_repr': color_repr, 'decolor': decolor}
144
+
145
+
146
+ def main():
147
+ args = odooly.get_parser().parse_args()
148
+
149
+ if args.config:
150
+ odooly.Client._config_file = odooly.Path.cwd() / args.config
151
+ if args.list_env:
152
+ print('Available settings: ' + ' '.join(odooly.read_config()))
153
+ return
154
+
155
+ global_vars = odooly.Client._set_interactive()
156
+ if odooly.color_py is not str or (os.getenv('FORCE_COLOR') and not os.getenv('NO_COLOR')):
157
+ global_vars.update(patch_colors(odooly))
158
+
159
+ print(odooly.color_repr(odooly.USAGE))
160
+ odooly.connect_client(args)
161
+ odooly._interact(global_vars)
162
+
163
+
164
+ if __name__ == '__main__':
165
+ main()
@@ -1,131 +0,0 @@
1
- #!/usr/bin/env python
2
- """Connect to an Odoo server."""
3
- # Colorize command-line output for Python <= 3.13
4
- import builtins
5
- import keyword
6
- import io
7
- import os
8
- import pathlib
9
- import re
10
- import token as T
11
- import tokenize
12
- import unicodedata
13
-
14
- import odooly
15
-
16
- BUILTINS = {str(name) for name in dir(builtins) if name[:1] != '_'}
17
- T_STRING = {getattr(T, tk) for tk in dir(T) if 'STRING' in tk}
18
- T_MIDDLE = {getattr(T, tk) for tk in dir(T) if 'STRING_MIDDLE' in tk}
19
-
20
- issoftkeyword = getattr(keyword, 'issoftkeyword', lambda t: False)
21
-
22
-
23
- class THEME: # Default theme
24
- keyword = '\x1b[1;34m'
25
- builtin = '\x1b[36m'
26
- comment = '\x1b[31m'
27
- string = '\x1b[32m'
28
- number = '\x1b[33m'
29
- op = '\x1b[0m' # No color
30
- definition = '\x1b[1m'
31
- reset = '\x1b[0m'
32
-
33
-
34
- def gen_colors(value, theme=THEME()):
35
- """Generate color tuples (start, end, color, reset_color)."""
36
- sio = io.StringIO(value)
37
- line_lengths = [0] + [len(line) for line in sio.readlines()]
38
- for i in range(1, len(line_lengths)):
39
- line_lengths[i] += line_lengths[i-1]
40
-
41
- sio.seek(0)
42
- bracket_level = is_def_name = 0
43
- try:
44
- for token in tokenize.generate_tokens(sio.readline):
45
- if token.start == token.end:
46
- continue
47
- color, end_offset = None, -1
48
- if token.type in T_STRING:
49
- if token.type in T_MIDDLE and token.string.endswith(('{', '}')):
50
- end_offset += 1
51
- color = theme.string
52
- elif token.type == T.COMMENT:
53
- color = theme.comment
54
- elif token.type == T.NUMBER:
55
- color = theme.number
56
- elif token.type == T.OP:
57
- if token.string in '([{':
58
- bracket_level += 1
59
- elif token.string in ')]}':
60
- bracket_level -= 1
61
- color = theme.op
62
- elif token.type == T.NAME:
63
- if is_def_name:
64
- is_def_name = False
65
- color = theme.definition
66
- elif keyword.iskeyword(token.string):
67
- if token.string in ('def', 'class'):
68
- is_def_name = True
69
- color = theme.keyword
70
- elif issoftkeyword(token.string) and not bracket_level:
71
- color = theme.keyword # soft_keyword
72
- elif token.string in BUILTINS:
73
- color = theme.builtin
74
- if color is not None:
75
- start = line_lengths[token.start[0] - 1] + token.start[1]
76
- end = line_lengths[token.end[0] - 1] + token.end[1] + end_offset
77
- yield start, end, color, theme.reset
78
- except (SyntaxError, tokenize.TokenError):
79
- pass
80
- yield None, None, None, None
81
-
82
-
83
- def colorize(value):
84
- """Colorize Python syntax."""
85
- output = ''
86
- if value:
87
- start, end, color, c_reset = next(colors := gen_colors(value))
88
-
89
- for idx, char in enumerate(value):
90
- if start == idx:
91
- output += color
92
-
93
- if char > '\x7f' and unicodedata.category(char)[:1] == 'C':
94
- char = rf'\u{ord(char):04x}'
95
- output += char
96
-
97
- if end == idx:
98
- output += c_reset
99
- start, end, color, c_reset = next(colors)
100
- return output
101
-
102
-
103
- def patch_colors(module):
104
- """Set functions to color output."""
105
- module.colorize = colorize
106
- module.color_bold = f'{THEME.definition}{{}}{THEME.reset}'.format
107
- module.color_comment = f'{THEME.comment}{{}}{THEME.reset}'.format
108
- decolor = odooly.partial(re.compile(r'\x1b\[[;\d]+m').sub, '')
109
- return {'colorize': colorize, 'decolor': decolor}
110
-
111
-
112
- def main():
113
- args = odooly.get_parser().parse_args()
114
-
115
- if args.config:
116
- odooly.Client._config_file = odooly.Path.cwd() / args.config
117
- if args.list_env:
118
- print('Available settings: ' + ' '.join(odooly.read_config()))
119
- return
120
-
121
- global_vars = odooly.Client._set_interactive()
122
- if os.getenv('FORCE_COLOR') and not os.getenv('NO_COLOR') and odooly.colorize is str:
123
- global_vars.update(patch_colors(odooly)) # Python <= 3.13
124
-
125
- print(odooly.colorize(odooly.USAGE))
126
- odooly.connect_client(args)
127
- odooly._interact(global_vars)
128
-
129
-
130
- if __name__ == '__main__':
131
- main()
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