File indexing completed on 2024-04-21 04:01:48
0001 # -*- coding: utf-8 -*- 0002 0003 """ 0004 Copyright (C) 2008-2016 Wolfgang Rohdewald <wolfgang@rohdewald.de> 0005 0006 SPDX-License-Identifier: GPL-2.0 0007 0008 """ 0009 0010 from collections import defaultdict 0011 import datetime 0012 import sys 0013 import os 0014 import shutil 0015 import logging 0016 import logging.handlers 0017 import socket 0018 from signal import signal, SIGABRT, SIGINT, SIGTERM 0019 0020 from qt import QStandardPaths, QObject, QGraphicsItem, QSize 0021 from qtpy import PYSIDE_VERSION, QT5, QT6, compat 0022 from qtpy.compat import isalive as qtpy_isalive 0023 0024 # pylint: disable=invalid-name 0025 0026 Internal = None 0027 0028 if os.name == 'nt': 0029 # This is only needed for manual execution, and 0030 # we expect python to be the python3 interpreter. 0031 # The windows installer will use kajongg.exe and kajonggserver.exe 0032 interpreterName = 'python' 0033 else: 0034 interpreterName = 'python3' 0035 0036 LIGHTSOURCES = ['NE', 'NW', 'SW', 'SE'] 0037 0038 def isAlive(qobj: QObject) ->bool: 0039 """check if the underlying C++ object still exists""" 0040 if qobj is None: 0041 return False 0042 result = qtpy_isalive(qobj) 0043 if not result and Debug.isalive: 0044 print('NOT alive:', repr(qobj)) 0045 return result 0046 0047 def serverAppdataDir(): 0048 """ 0049 The per user directory with kajongg application information like the database. 0050 0051 @return: The directory path. 0052 @rtype: C{str}. 0053 """ 0054 serverDir = os.path.expanduser('~/.kajonggserver/') 0055 # the server might or might not have KDE installed, so to be on 0056 # the safe side we use our own .kajonggserver directory 0057 # the following code moves an existing kajonggserver.db to .kajonggserver 0058 # but only if .kajonggserver does not yet exist 0059 kdehome = os.environ.get('KDEHOME', '~/.kde') 0060 oldPath = os.path.expanduser( 0061 kdehome + 0062 '/share/apps/kajongg/kajonggserver.db') 0063 if not os.path.exists(oldPath): 0064 oldPath = os.path.expanduser( 0065 '~/.kde' +'4/share/apps/kajongg/kajonggserver.db') 0066 if os.path.exists(oldPath) and not os.path.exists(serverDir): 0067 # upgrading an old kajonggserver installation 0068 os.makedirs(serverDir) 0069 shutil.move(oldPath, serverDir) 0070 if not os.path.exists(serverDir): 0071 try: 0072 os.makedirs(serverDir) 0073 except OSError: 0074 pass 0075 return serverDir 0076 0077 0078 def clientAppdataDir(): 0079 """ 0080 The per user directory with kajongg application information like the database. 0081 0082 @return: The directory path. 0083 @rtype: C{str}. 0084 """ 0085 serverDir = os.path.expanduser('~/.kajonggserver/') 0086 if not os.path.exists(serverDir): 0087 # the client wants to place the socket in serverDir 0088 os.makedirs(serverDir) 0089 result = QStandardPaths.writableLocation(QStandardPaths.AppDataLocation) 0090 # this may end with kajongg.py or .pyw or whatever, so fix that: 0091 if not os.path.isdir(result): 0092 result = os.path.dirname(result) 0093 if not result.endswith('kajongg'): 0094 # when called first, QApplication.applicationName is not yet set 0095 result = result + '/kajongg' 0096 if not os.path.exists(result): 0097 os.makedirs(result) 0098 return result 0099 0100 0101 def appdataDir(): 0102 """ 0103 The per user directory with kajongg application information like the database. 0104 0105 @return: The directory path. 0106 @rtype: C{str}. 0107 """ 0108 return serverAppdataDir() if Internal.isServer else clientAppdataDir() 0109 0110 0111 def cacheDir(): 0112 """the cache directory for this user""" 0113 result = os.path.join(appdataDir(), '.cache') 0114 if not os.path.exists(result): 0115 os.makedirs(result) 0116 return result 0117 0118 0119 def socketName(): 0120 """client and server process use this socket to talk to each other""" 0121 serverDir = os.path.expanduser('~/.kajonggserver') 0122 if not os.path.exists(serverDir): 0123 appdataDir() 0124 # allocate the directory and possibly move old databases 0125 # there 0126 if Options.socket: 0127 return Options.socket 0128 return os.path.normpath('{}/socket{}'.format(serverDir, Internal.defaultPort)) 0129 0130 0131 def handleSignals(handler): 0132 0133 """set up signal handling""" 0134 0135 signal(SIGABRT, handler) 0136 signal(SIGINT, handler) 0137 signal(SIGTERM, handler) 0138 if os.name != 'nt': 0139 from signal import SIGHUP, SIGQUIT 0140 signal(SIGHUP, handler) 0141 signal(SIGQUIT, handler) 0142 0143 0144 class Debug: 0145 0146 """holds flags for debugging output. At a later time we might 0147 want to add command line parameters for initialisation, and 0148 look at kdebugdialog""" 0149 connections = False 0150 traffic = False 0151 process = False 0152 time = False 0153 sql = False 0154 animation = '' # 'yeysywynG87gfefsfwfn' for tiles and G#g for groups where # is the uid 0155 animationSpeed = False 0156 robotAI = False 0157 dangerousGame = False 0158 originalCall = False 0159 modelTest = False 0160 focusable = '' 0161 robbingKong = False 0162 mahJongg = False 0163 sound = False 0164 chat = False 0165 argString = None 0166 scores = False 0167 hand = False 0168 explain = False 0169 random = False 0170 deferredBlock = False 0171 stack = False 0172 events = '' 0173 table = False 0174 gc = False 0175 delayChow = False 0176 locate = False 0177 neutral = False # only neutral comparable debug output 0178 callers = '0' 0179 git = False 0180 ruleCache = False 0181 quit = False 0182 preferences = False 0183 graphics = False 0184 scoring = False 0185 wallSize = '0' 0186 i18n = False 0187 isalive = False 0188 0189 def __init__(self): 0190 raise Exception('Debug is not meant to be instantiated') 0191 0192 @staticmethod 0193 def help(): 0194 """a string for help texts about debug options""" 0195 def optYielder(options): 0196 """yields options with markers for line separation""" 0197 for idx, opt in enumerate(options): 0198 yield opt 0199 if idx < len(options) - 1 and idx % 5 == 4: 0200 yield 'SEPARATOR' 0201 options = [x for x in Debug.__dict__ if not x.startswith('_')] 0202 boolOptions = sorted(x for x in options 0203 if isinstance(Debug.__dict__[x], bool)) 0204 stringOptions = sorted(x for x in options 0205 if isinstance(Debug.__dict__[x], str)) 0206 stringExample = '%s:%s' % (stringOptions[0], 's3s4') 0207 allOptions = sorted(boolOptions + stringOptions) 0208 opt = '\n'.join( 0209 ', '.join(optYielder(allOptions)).split(' SEPARATOR, ')) 0210 # TODO: i18n for this string. First move i18n out of kde so we can import it here 0211 return """set debug options. Pass a comma separated list of options. 0212 Options are: {opt}. 0213 Options {stropt} take a string argument like {example}. 0214 --debug=events can get suboptions like in --debug=events:Mouse:Hide 0215 showing all event messages with 'Mouse' or 'Hide' in them""".format( 0216 opt=opt, 0217 stropt=', '.join(stringOptions), example=stringExample) 0218 0219 @staticmethod 0220 def setOptions(args): 0221 """args comes from the command line. Put this in the Debug class. 0222 If something goes wrong, return an error message.""" 0223 if not args: 0224 return None 0225 Debug.argString = args 0226 for arg in args.split(','): 0227 parts = arg.split(':') 0228 option = parts[0] 0229 if len(parts) == 1: 0230 value = True 0231 else: 0232 value = ':'.join(parts[1:]) 0233 if option not in Debug.__dict__: 0234 return '--debug: unknown option %s' % option 0235 if not isinstance(Debug.__dict__[option], type(value)): 0236 return ('--debug: wrong type for option %s: ' 0237 'given %s/%s, should be %s') % ( 0238 option, value, type(value), 0239 type(Debug.__dict__[option])) 0240 if option != 'scores' or not Internal.isServer: 0241 type.__setattr__(Debug, option, value) 0242 if Debug.time: 0243 Debug.time = datetime.datetime.now() 0244 if Debug.modelTest and not Debug.modeltest_is_supported(): 0245 print('--debug=modelTest is not yet supported for pyside, use pyqt') 0246 sys.exit(2) 0247 return None 0248 0249 @staticmethod 0250 def modeltest_is_supported(): 0251 """Is the QT binding supported.""" 0252 try: 0253 import sip 0254 except ImportError: 0255 return False 0256 try: 0257 _ = sip.cast(QSize(), QSize) 0258 return True 0259 except TypeError: 0260 return False 0261 0262 @staticmethod 0263 def str(): 0264 """__str__ does not work with class objects""" 0265 result = [] 0266 for option in Debug.__dict__: 0267 if not option.startswith('_'): 0268 result.append('{}={}'.format(option, getattr(Debug, option))) 0269 return ' '.join(result) 0270 0271 class FixedClass(type): 0272 0273 """Metaclass: after the class variable fixed is set to True, 0274 all class variables become immutable""" 0275 def __setattr__(cls, key, value): 0276 if cls.fixed: 0277 raise SystemExit('{cls}.{key} may not be changed'.format( 0278 cls=cls.__name__, key=key)) 0279 type.__setattr__(cls, key, value) 0280 0281 0282 class StrMixin: 0283 0284 """ 0285 A mixin defining a default for __repr__, 0286 using __str__. If __str__ is not defined, this runs 0287 into recursion. But I see no easy way without too much 0288 runtime overhead to check for this beforehand. 0289 """ 0290 0291 def __repr__(self): 0292 clsName = self.__class__.__name__ 0293 content = str(self) 0294 if content.startswith(clsName): 0295 return content 0296 return '{cls}({content})'.format(cls=clsName, content=content) 0297 0298 0299 class Options(metaclass=FixedClass): 0300 0301 """they are never saved in a config file. Some of them 0302 can be defined on the command line.""" 0303 demo = False 0304 showRulesets = False 0305 rulesetName = None # will only be set by command line --ruleset 0306 ruleset = None # from rulesetName 0307 rounds = None 0308 host = None 0309 player = None 0310 dbPath = None 0311 socket = None 0312 port = None 0313 playOpen = False 0314 gui = False 0315 AI = 'DefaultAI' 0316 csv = None 0317 continueServer = False 0318 fixed = False 0319 0320 def __init__(self): 0321 raise Exception('Options is not meant to be instantiated') 0322 0323 @staticmethod 0324 def str(): 0325 """__str__ does not work with class objects""" 0326 result = [] 0327 for option in Options.__dict__: 0328 if not option.startswith('_'): 0329 value = getattr(Options, option) 0330 if isinstance(value, (bool, int, str)): 0331 result.append('{}={}'.format(option, value)) 0332 return ' '.join(result) 0333 0334 class SingleshotOptions: 0335 0336 """Options which are cleared after having been used once""" 0337 table = False 0338 join = False 0339 game = None 0340 0341 0342 class __Internal: 0343 0344 """ 0345 Global things. 0346 0347 @cvar Preferences: The L{SetupPreferences}. 0348 @type Preferences: L{SetupPreferences} 0349 @cvar version: The version of Kajongg. 0350 @type version: C{str} 0351 @cvar logPrefix: C for client and S for server. 0352 @type logPrefix: C{str} 0353 @cvar isServer: True if this is the server process. 0354 @type isServer: C{bool} 0355 @cvar scaleScene: Defines if the scene is scaled. 0356 Disable for debugging only. 0357 @type scaleScene: C{bool} 0358 @cvar reactor: The twisted reactor instance. 0359 @type reactor: L{twisted.internet.reactor} 0360 @cvar app: The Qt or KDE app instance 0361 @type app: L{KApplication} 0362 @cvar db: The sqlite3 data base 0363 @type db: L{DBHandle} 0364 @cvar scene: The QGraphicsScene. 0365 @type scene: L{PlayingScene} or L{ScoringScene} 0366 """ 0367 # pylint: disable=too-many-instance-attributes 0368 Preferences = None 0369 defaultPort = 8301 0370 logPrefix = 'C' 0371 isServer = False 0372 scaleScene = True 0373 reactor = None 0374 app = None 0375 db = None 0376 scene = None 0377 mainWindow = None 0378 game = None 0379 autoPlay = False 0380 logger = None 0381 kajonggrc = None 0382 0383 def __init__(self): 0384 """init the loggers""" 0385 global Internal # pylint: disable=global-statement 0386 Internal = self 0387 logName = os.path.basename(sys.argv[0]).replace('.py', '').replace('.exe', '') + '.log' 0388 self.logger = logging.getLogger(logName) 0389 if os.name == 'nt': 0390 haveDevLog = False 0391 else: 0392 try: 0393 handler = logging.handlers.SysLogHandler('/dev/log') 0394 haveDevLog = True 0395 except (AttributeError, socket.error): 0396 haveDevLog = False 0397 if not haveDevLog: 0398 logName = os.path.join(appdataDir(), logName) 0399 handler = logging.handlers.RotatingFileHandler( 0400 logName, maxBytes=100000000, backupCount=10) 0401 self.logger.addHandler(handler) 0402 self.logger.addHandler(logging.StreamHandler(sys.stderr)) 0403 self.logger.setLevel(logging.DEBUG) 0404 formatter = logging.Formatter("%(name)s: %(levelname)s %(message)s") 0405 handler.setFormatter(formatter) 0406 0407 __Internal() 0408 0409 0410 class IntDict(defaultdict, StrMixin): 0411 0412 """a dict where the values are expected to be numeric, so 0413 we can add dicts.If parent is given, parent is expected to 0414 be another IntDict, and our changes propagate into parent. 0415 This allows us to have a tree of IntDicts, and we only have 0416 to update the leaves, getting the sums for free""" 0417 0418 def __init__(self, parent=None): 0419 defaultdict.__init__(self, int) 0420 self.parent = parent 0421 0422 def copy(self): 0423 """need to reimplement this because the __init__ signature of 0424 IntDict is not identical to that of defaultdict""" 0425 result = IntDict(self.parent) 0426 defaultdict.update(result, self) 0427 # see https://www.logilab.org/ticket/23986 0428 return result 0429 0430 def __add__(self, other): 0431 """add two IntDicts""" 0432 result = self.copy() 0433 for key, value in other.items(): 0434 result[key] += value 0435 return result 0436 0437 def __radd__(self, other): 0438 """we want sum to work (no start value)""" 0439 assert other == 0 0440 return self.copy() 0441 0442 def __sub__(self, other): 0443 """self - other""" 0444 result = self.copy() 0445 for key, value in other.items(): 0446 result[key] -= value 0447 for key in defaultdict.keys(result): 0448 if result[key] == 0: 0449 del result[key] 0450 return result 0451 0452 def __eq__(self, other): 0453 return self.all() == other.all() 0454 0455 def __ne__(self, other): 0456 return self.all() != other.all() 0457 0458 def count(self, countFilter=None): 0459 """how many tiles defined by countFilter do we hold? 0460 countFilter is an iterator of element names. No countFilter: Take all 0461 So count(['we', 'ws']) should return 8""" 0462 return sum((defaultdict.get(self, x) or 0) 0463 for x in countFilter or self) 0464 0465 def all(self, countFilter=None): 0466 """return a list of all tiles defined by countFilter, 0467 each tile multiplied by its occurrence. 0468 countFilter is an iterator of element names. No countFilter: take all 0469 So all(['we', 'fs']) should return ['we', 'we', 'we', 'we', 'fs']""" 0470 result = [] 0471 for element in countFilter or self: 0472 result.extend([element] * self[element]) 0473 return sorted(result) 0474 0475 def __contains__(self, tile): 0476 """does not contain tiles with count 0""" 0477 return defaultdict.__contains__(self, tile) and self[tile] > 0 0478 0479 def __setitem__(self, key, value): 0480 """also update parent if given""" 0481 if self.parent is not None: 0482 self.parent[key] += value - defaultdict.get(self, key, 0) 0483 defaultdict.__setitem__(self, key, value) 0484 0485 def __delitem__(self, key): 0486 """also update parent if given""" 0487 if self.parent is not None: 0488 self.parent[key] -= defaultdict.get(self, key, 0) 0489 defaultdict.__delitem__(self, key) 0490 0491 def clear(self): 0492 """also update parent if given""" 0493 if self.parent is not None: 0494 for key, value in defaultdict.items(self): 0495 self.parent[key] -= value 0496 defaultdict.clear(self) 0497 0498 def __str__(self): 0499 """sort the result for better log comparison""" 0500 keys = sorted(self.keys()) 0501 return ', '.join('{}:{}'.format( 0502 str(x), str(self[x])) for x in keys) 0503 0504 0505 class ZValues: 0506 0507 """here we collect all zValues used in Kajongg""" 0508 itemZFactor = 100000 0509 boardZFactor = itemZFactor * 100 0510 markerZ = boardZFactor * 100 + 1 0511 movingZ = markerZ + 1 0512 popupZ = movingZ + 1 0513 0514 0515 class Speeds: 0516 """some fixed animation speeds""" 0517 windMarker = 20 0518 sideText = 60 0519 0520 0521 class DrawOnTopMixin: 0522 0523 """The inheriting QGraphicsObject will draw itself above all non moving tiles""" 0524 0525 def setDrawingOrder(self): 0526 """we want us above all non moving tiles""" 0527 if self.activeAnimation.get('pos'): 0528 movingZ = ZValues.movingZ 0529 else: 0530 movingZ = 0 0531 self.setZValue(ZValues.markerZ + movingZ)