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)