Compare commits

...

55 commits

Author SHA1 Message Date
342f48042c api.auth: HTTP Basic Auth working 2026-11-09 11:36:56 +01:00
8963c2ac87 api: improvements, storage users as dependency 2026-11-09 09:21:18 +01:00
a139fd784e api.auth: start with simple dummy (testing) authentication 2026-11-08 22:28:25 +01:00
fc0616ce6a api: extend testing; work in progress: authentication 2026-11-08 17:08:37 +01:00
2c43df83a5 storage: fix tracking update logic; api.auth.user: split model (view) classes 2026-11-07 11:16:30 +01:00
1709290ea9 api.auth.user: improve password hashing 2026-11-07 09:36:17 +01:00
b6e6f1c833 api.auth.user: POST with hashing of input password 2026-11-06 19:16:18 +01:00
8494f3737d api, storage: POST user; + more fixes and improvements 2026-11-06 15:19:53 +01:00
7d309491fc fixes and improvements, make mypy happy, prepayre auth stuff 2026-11-06 09:33:21 +01:00
12c099b06b api.auth: User with password hash 2026-11-05 12:54:30 +01:00
e0a53dce68 shape: generic asDict and toObject methods 2026-11-05 10:55:03 +01:00
f7ba9c3881 api, work in progress: authn/authz, starting with user info 2026-11-04 23:37:19 +01:00
dc3acc5da5 api: move transaction handlung to middleware; + config fixes 2026-11-03 08:57:23 +01:00
2b02de2012 create central config.py based on pydantic_settings 2026-11-02 09:43:53 +01:00
0a60389152 api/tracking: add POST route 2026-10-30 19:12:05 +01:00
d3850cce2e api/tracking: simple data model and route for querying tracks 2026-10-30 17:33:43 +01:00
8ee3c8d015 api.tracking: basic scaffolding for routers and views (fastapi models) 2026-10-29 19:43:32 +01:00
43921da820 prepare storage (postgres) tests for pytest 2026-10-29 11:19:11 +01:00
d8546b4a92 storage testing, work in progress: use pytest, with postgres only 2026-10-28 23:14:29 +01:00
5df3e3fa41 move user.py to web/auth, + fix Zope tests 2026-10-28 11:31:05 +01:00
87c2a68ef6 core.actor: Mailbox class (with status), send() function 2026-10-28 09:34:08 +01:00
f0de1a3e7a core.actor: try_send() 2026-10-27 09:30:20 +01:00
c78e1ac067 core: extend synapse: may return Symbol, handle in forward() 2026-10-27 08:57:35 +01:00
bc12966b70 core, work in progress: basic actions, env notifications 2026-10-26 11:56:20 +01:00
334df9afbd util, core: improve keyword definitions and handling 2026-10-24 10:45:59 +02:00
9ee4dd73aa util: Symbols / keywords improvements, define symbol lists with namespaces 2026-10-23 23:12:33 +02:00
717ce25372 core: proc_std, create action: basically working 2026-10-23 09:33:46 +02:00
965543252f core: more rearrangements; util: keyword_dict function 2026-10-22 22:15:26 +02:00
affe948296 core: std_proc; rearrange definitions for clarity and other improvments 2026-10-22 14:19:29 +02:00
8f277a5263 core: reorganize / improve definition of basic functions 2026-10-22 09:02:15 +02:00
16620d0a69 core, work in progress: basic action handlers 2026-10-21 10:08:15 +02:00
db6eccd974 core: synapse implementation (with op for changing or cancelling msg) 2026-10-20 13:26:45 +02:00
effd9cf412 core: gen_program, using ProgramSpec dict with (cat, stage) as key 2026-10-20 09:30:23 +02:00
49cac25cb7 core: action and handler types, gen_proc; revert proc and handler args order 2026-10-19 22:41:09 +02:00
4b3af802cd core: creation of simple zero neuron 2026-10-18 22:29:22 +02:00
ed48076e67 core: setting up env neuron basically working 2026-10-18 16:35:59 +02:00
365cbfd5fd core: typing and dataclass improvements, start creation of env and csys 2026-10-18 09:24:34 +02:00
c1bff58ad3 core, work in progress: scope creation, environment setup 2026-10-17 22:36:06 +02:00
48a5105eea core: improve mypy type checks without generating runtime problems 2026-10-17 17:25:49 +02:00
9aebbff176 work in progress: core package: add Scope class, rearrange tests, add type annotations 2026-10-17 09:41:42 +02:00
353109e105 basic Message class, with tests 2026-10-06 09:36:32 +02:00
1bc2583de8 move common test stuff to testing package 2026-10-05 14:55:20 +02:00
5f9d8a73d2 rearrange class hierarchies for Record / Track with regard to shape, core, storage packages 2026-10-03 18:51:30 +02:00
5b54e76327 core.actor: start with simple actor implementation 2026-10-01 22:29:05 +02:00
f0745f4a7b util: + Symbol and keywords stuff (with tests) 2026-10-01 12:09:36 +02:00
afe858fc9f clean separation of zope.testrunner tests (in scopes/tests) and pytest tests (in tests) 2026-10-01 09:17:18 +02:00
b6ef05008b fix logging config in legacy (zope) tests (using pydantic_settings) 2026-09-20 08:11:57 +02:00
f01e231e7a config: use pydantic_settings; apitest: with logging, config improvements 2026-09-20 08:01:26 +02:00
5478ce98a7 apidemo, test_api: basically working with simple (old) config 2026-09-18 16:43:06 +02:00
b3d6512987 apitest: query tracks 2026-09-18 10:44:10 +02:00
635b18bd08 apitest: include storage (postgres DB, tracking) setup 2026-09-16 18:54:25 +02:00
37d7a31650 start with test_api (async, using fastapi) 2026-09-14 18:15:41 +02:00
7b9b3e2d47 more experiments with fastapi and async / await 2026-09-03 10:19:28 +02:00
778324ad33 add fastapi as optional dependency, + first experiments 2026-07-04 21:27:24 +02:00
db1f78f4e6 some experiments with async / await: set up a simple actor framework 2026-07-04 21:23:13 +02:00
43 changed files with 1171 additions and 105 deletions

2
.gitignore vendored
View file

@ -8,10 +8,12 @@
*.sublime-project
*.sublime-workspace
*.ropeproject
uv.lock
.env
.private*
.pytest.ini
*#*#
*.#*
__pycache__
log
var

1
apidemo/__init__.py Normal file
View file

@ -0,0 +1 @@
# py-scopes: apidemo

2
demo/__init__.py Normal file
View file

@ -0,0 +1,2 @@
# py-scopes: demo

View file

@ -7,13 +7,12 @@ name = "py-scopes"
version = "3.0.1"
description = "Implementation of the unknown 'scopes' paradigm in Python"
readme = "README.md"
license = {text = "MIT"}
license = "MIT"
license-files = ["LICENSE"]
keywords = ["scopes"]
authors = [{name = "Helmut Merz", email = "helmutm@cy55.de"}]
dependencies = [
"SQLAlchemy",
]
dependencies = ["SQLAlchemy", "pydantic", "pydantic-settings"]
[project.optional-dependencies]
postgres = [
@ -21,7 +20,7 @@ postgres = [
"transaction",
"zope.sqlalchemy",
]
app = [
zope = [
"python-dotenv",
"waitress",
"zope.authentication",
@ -29,15 +28,23 @@ app = [
"zope.publisher",
"zope.traversing",
]
auth = ["pyjwt[crypto]", "cryptography", "requests"]
test = ["zope.testrunner"]
#test = ["pytest"]
api = ["fastapi[standard]"]
auth = ["authlib", "pyjwt[crypto]", "cryptography", "httpx2",
"pwdlib[argon2]", "requests"]
zopetest = ["zope.testrunner"]
apitest = ["pytest", "httpx2"]
[tool.setuptools]
packages = ["scopes"]
#[tool.pytest.ini_options]
#addopts = "-vv"
#python_files = "test_standard.py" # default: run only `standard` tests
# use .pytest.ini file with `python_files = test_*.py` to run all tests
[tool.pytest.ini_options]
addopts = ["-s"]
testpaths = ["tests"]
#python_files = ["test_api.py"]
# use .pytest.ini file for local test config
[tool.mypy]
packages = ['scopes.api', 'scopes.core', 'scopes.shape',
'scopes.storage', 'scopes.util']
follow_imports = 'skip'

View file

@ -1,5 +1,6 @@
# runtests.sh
# run all unit / doc tests
#zope-testrunner --test-path=. -m !test_api $*
zope-testrunner --test-path=. $*

1
scopes/api/__init__.py Normal file
View file

@ -0,0 +1 @@
"""package scopes.api"""

View file

@ -0,0 +1 @@
"""package scopes.api.auth"""

17
scopes/api/auth/config.py Normal file
View file

@ -0,0 +1,17 @@
# scopes.api.auth.config
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Any
from scopes.storage.db.postgres import StorageFactory
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file='.env', extra='ignore')
#, env_prefix='SCOPES_')
auth_method: str = 'oauth2'
jwt_key: str | None = None
jwt_algorithm: str = 'HS256'
settings = Settings()
auth_methods: dict[str, Any] = {}

87
scopes/api/auth/user.py Normal file
View file

@ -0,0 +1,87 @@
# scopes.api.auth user
from datetime import datetime
from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi.security import HTTPBasic, HTTPBasicCredentials
import jwt
from pwdlib import PasswordHash
from pydantic import BaseModel, Field
from typing import Annotated, List
from scopes.api import deps
from scopes.api.auth import config
from scopes.storage import user as st_user
from scopes.storage.common import Storage
class UserBase(BaseModel):
name: str
login: str
display_name: str
email: str | None = None
groups: List[str] = Field(default_factory=list)
class User(UserBase):
uid: str | None = None
timeStamp: datetime | None = None
pw_hash: str | None = Field(exclude=True, default=None)
class UserEdit(UserBase):
password: str | None = None
def hashPassword(self) -> str | None:
pw = self.password
return pw and hashPassword(pw) or None
# password handling, authentication
def dummy_auth() -> User:
return User(name='tester', login='tester', display_name='Tester')
config.auth_methods['testing'] = dummy_auth
pw_hash = PasswordHash.recommended()
def hashPassword(pw):
return pw_hash.hash(pw)
def verifyPassword(plain, hashed):
return pw_hash.verify(plain, hashed)
security = HTTPBasic()
type Credentials = Annotated[HTTPBasicCredentials, Depends(security)]
def basicAuth(users: deps.Users, cred: Credentials) -> User:
tr = users.queryLast(login=cred.username)
users.storage.commit()
user = tr.toObject(User)
if verifyPassword(cred.password, user.pw_hash):
return user
raise HTTPException(status_code=401, detail='credentials',
headers={"WWW-Authenticate": "Basic"})
config.auth_methods['basic'] = basicAuth
def userFromToken(users: deps.Users, token: deps.AuthToken) -> User:
payload = jwt.decode(token, config.settings.jwt_key,
algorithms=[config.settings.jwt_algorithm])
userName = payload.get('sub')
tr = users.queryLast(name=userName)
users.storage.commit()
return tr.toObject(User)
# routes
router = APIRouter()
@router.get('/users', response_model=list[User])
async def get_users(users: deps.Users):
result = [tr.toObject(User) for tr in users.query()]
return result
@router.post('/users', response_model=User)
async def post_user(user: Annotated[UserEdit, Body()], users: deps.Users,
auth_user: deps.AuthUser):
tr = st_user.User.fromObject(user)
users.save(tr)
#print('*** post_user', auth_user, tr)
return tr.toObject(User)

37
scopes/api/deps.py Normal file
View file

@ -0,0 +1,37 @@
# scopes.api.deps
"""Common dependencies and other definitions for Web API"""
from fastapi import Depends
from typing import Annotated
from scopes import config
from scopes.api.auth import config as auth_config
from scopes.storage import common
from scopes.storage import user as st_user
type Storage = Annotated[common.Storage, Depends(storage)]
type Users = Annotated[st_user.Users, Depends(users)]
type AuthUser = Annotated[str, Depends(auth_user())]
type AuthToken = Annotated[str, Depends(auth_token)]
# storage
def storage():
stf = config.StorageFactory(config.settings)
return stf(config.settings.dbschema)
def users(storage: Storage):
return storage.create(st_user.Users)
# auth
def auth_user():
auth = auth_config.auth_methods.get(auth_config.settings.auth_method)
if auth is not None:
return auth
def auth_token():
return None

41
scopes/api/main.py Normal file
View file

@ -0,0 +1,41 @@
# scopes.api.main
import sys
if '' not in sys.path:
sys.path = [''] + sys.path
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
import transaction # type: ignore
from scopes.api import tracking
from scopes.api.auth import user
# app and routes
app = FastAPI()
@app.middleware('http')
async def handle_transactions(request: Request, call_next):
response = await call_next(request)
if response.status_code >= 400:
transaction.abort()
else:
transaction.commit()
return response
app.include_router(tracking.router)
app.include_router(user.router)
@app.get('/')
async def read_root():
return {'Hello': 'World'}
@app.get('/demo')
async def read_demo():
return HTMLResponse('<b>Hello World</b>')
@app.get('/persons/{name}')
async def read_person(name: str, q: str | None = None):
return {'name': name, 'query': q}

36
scopes/api/tracking.py Normal file
View file

@ -0,0 +1,36 @@
# scopes.api.tracking
"""basic (generic) routes and views for tracks (records)"""
from datetime import datetime
from fastapi import APIRouter, Body
from pydantic import BaseModel, Field
from typing import Annotated, Any, Self
from scopes.api import deps
from scopes.storage import tracking
class Track(BaseModel):
uid: str | None = None
head: dict[str, str]
data: dict[str, Any]
timeStamp: datetime | None = None
# routes
router = APIRouter()
@router.get('/tracks', response_model=list[Track])
async def get_tracks(storage: deps.Storage):
tracks = storage.create(tracking.Container)
result = [tr.asDict() for tr in tracks.query()]
return result
@router.post('/tracks', response_model=Track)
async def post_track(track: Annotated[Track, Body()], storage: deps.Storage):
tracks = storage.create(tracking.Container)
hd = track.head
tr = tracking.Track(hd['taskId'], hd['userName'], data=track.data)
tracks.save(tr)
return tr.asDict()

30
scopes/config.py Normal file
View file

@ -0,0 +1,30 @@
# scopes.config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
from scopes.storage.db.postgres import StorageFactory
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file='.env', extra='ignore')
#, env_prefix='SCOPES_')
base_url: str = 'https://test.cy7.eu'
log_file: str = 'log/scopes.log'
log_level: str = 'INFO'
log_format: str = '%(asctime)s %(levelname)s %(name)s %(message)s'
log_dateformat: str = '%Y-%m-%dT%H:%M:%S'
dbengine: str = 'postgresql+psycopg'
dbname: str = 'demo'
dbuser: str = 'demo'
dbpassword: str = 'secret'
dbschema: str = 'demo'
settings = Settings()
import logging
logging.basicConfig(filename=settings.log_file, level=settings.log_level,
format=settings.log_format, datefmt=settings.log_dateformat)
logging.getLogger('uvicorn.error').propagate = True

4
scopes/core/__init__.py Normal file
View file

@ -0,0 +1,4 @@
"""package scopes.core"""
from .core import *

40
scopes/core/actor.py Normal file
View file

@ -0,0 +1,40 @@
# scopes.core.actor
import asyncio
from collections.abc import Awaitable, Callable
from typing import Any, Optional
from scopes.util import keywords as k, Symbol
# keywords:
k('active full ok quit retired suspended')
type Behavior = Callable[[Any], Awaitable[Behavior | None]]
class Mailbox:
def __init__(self, maxsize:int = 0, status: Symbol = k.active):
self.queue: asyncio.Queue = asyncio.Queue(maxsize)
self.status: Symbol = status
async def loop(q: asyncio.Queue, bhv: Behavior) -> None:
while True:
msg = await q.get()
if msg is k.quit:
break
bhv = await bhv(msg) or bhv
q.task_done()
def create(bhv: Behavior, maxsize: int = 0) -> Mailbox:
mb = Mailbox(maxsize)
asyncio.create_task(loop(mb.queue, bhv))
return mb
async def send(mb: Mailbox, msg: Any, no_wait: bool = False) -> Symbol:
if mb.status is k.retired:
return k.retired
if no_wait and mb.queue.full():
return k.full
await mb.queue.put(msg)
return k.ok

175
scopes/core/core.py Normal file
View file

@ -0,0 +1,175 @@
# scopes.core.core
"""A concurrent cybernetic communication system"""
from __future__ import annotations # no check of type annotations at runtime
import asyncio
from collections.abc import Awaitable, Callable, Coroutine
from dataclasses import dataclass, field
from typing import Any, Self
from scopes.core import actor
from scopes.core.message import Message
from scopes import util
from scopes.util import keywords as k, Symbol, Symbols
# keywords (domains, actions, classes, stages, data keys):
k('csys env _meta',
'connect create created data default query set value',
'c00 e00 s00 op rcvr',
'initial active retired',
'address pred succ')
type Categ = tuple[Symbol, Symbol]
type Proc = Callable[[Message, Scope], Awaitable[Scope | None]]
type Program = Callable[[Scope], Proc]
type ProgramSpec = dict[tuple[Symbol, Symbol], Proc]
type Syn = Callable[[Message], Awaitable[Symbol | None]]
type SynOp = Callable[[Message], Message | None]
type Handler = Callable[[Message, Scope],
Awaitable[Message | None | tuple[Message | None, Scope | None]]]
type Handlers = Handler | None | list[Handlers]
type Actions = dict[Symbol, Handlers]
def neuron(scope: Scope) -> actor.Mailbox:
async def process(msg: Message) -> actor.Behavior | None:
nonlocal scope
scope = await scope.proc(msg, scope) or scope
return process
return actor.create(process)
def synapse(rcvr: actor.Mailbox, op: SynOp = lambda x: x) -> Syn:
async def syn(msg: Message) -> Symbol | None:
msg_new = op(msg)
if msg_new is not None:
return await actor.send(rcvr, msg_new)
return None
return syn
env_dummy: actor.Mailbox = actor.Mailbox()
def notify() -> Handler:
async def handle(msg: Message, scope: Scope) -> None:
await actor.send(scope.environ, msg)
return handle
def gen_proc(actions: Actions, default: Handlers = notify(),
prepare: Handlers = None, finalize: Handlers = None) -> Proc:
async def proc(msg: Message, scope: Scope) -> Scope:
acts = actions.get(msg.action, actions.get(k.default, default))
acts = util.flatten(prepare, acts, finalize, ignore_none=True)
for act in acts:
msg = await act(msg, scope)
if isinstance(msg, tuple):
msg, new_scope = msg
if new_scope is not None:
scope = new_scope
if msg is None:
break
return scope
return proc
def gen_program(spec: ProgramSpec, default: Proc = gen_proc({})) -> Program:
def program(scope: Scope) -> Proc:
key = (scope.categ[1], scope.stage)
proc = spec.get(key, spec.get((key[0], k.default)))
if proc is None:
proc = spec.get((k.default, key[1]), default)
return proc
return program
@dataclass
class Scope:
value: Any = 0
categ: Categ = (k.csys, k.c00)
stage: Symbol = k.initial
proc: Proc = field(init=False)
program: Program = gen_program({})
syns: list[Syn | None] = field(default_factory=list)
environ: actor.Mailbox = env_dummy
def __post_init__(self):
self.set_proc()
def set_proc(self) -> Self:
self.proc = self.program(self)
return self
def create_scope(env: actor.Mailbox, cat: Categ = (k.csys, k.c00), **kw) -> Scope:
return Scope(environ=env, categ=cat, **kw)
def clone_scope(scope: Scope, **kw) -> Scope:
value = kw.get('value', 0)
categ = kw.get('categ', scope.categ)
stage = kw.get('stage', k.initial)
return Scope(environ=scope.environ, value=value, categ=categ, stage=stage,
program=scope.program, syns=[])
async def create_zero(env: actor.Mailbox) -> actor.Mailbox:
scope = create_scope(env, program=gen_program({}, default=std_proc()))
zero = neuron(scope)
await notify_created(Message(k.csys), zero, scope)
return zero
# predefined action handlers and Action sets
def noop(no_forward: bool = False) -> Handler:
async def handle(msg: Message, scope: Scope) -> Message | None:
return None if no_forward else msg
return handle
def forward(no_syns_notify: bool = False) -> Handler:
do_notify = notify() if no_syns_notify else None
async def handle(msg: Message, scope: Scope) -> None:
count = 0
for idx, syn in enumerate(scope.syns):
res = await syn(msg) if syn is not None else None
match res:
case k.retired: scope.syns[idx] = None
case k.ok: count += 1
if count == 0 and do_notify:
do_notify(msg, scope)
return handle
def value_add() -> Handler:
async def handle(msg: Message, scope: Scope) -> tuple[Message, Scope]:
return msg, scope
return handle
def create() -> Handler:
async def handle(msg: Message, scope: Scope) -> None:
new = neuron(clone_scope(scope, **msg.data))
await notify_created(msg, new, scope)
conn = msg.data.get(k.connect)
return handle
def connect() -> Handler:
async def handle(msg: Message, scope: Scope) -> None:
return None
return handle
def basic_actions() -> Actions:
acts: Actions = {
k.value: value_add(),
k.create: create(),
k.connect: connect()
}
return acts
# predefined specialized procs
def std_proc(actions: Actions | None = None, finalize: Handler = forward(),
more: Actions = {}) -> Proc:
if actions is None:
actions = basic_actions()
actions.update(more)
return gen_proc(actions, finalize=finalize)
# helpers
async def notify_created(msg: Message, new: actor.Mailbox, scope: Scope) -> None:
msg = Message(msg.domain, k.created, data=msg.data | {k.new: new})
await notify()(msg, scope)

28
scopes/core/environ.py Normal file
View file

@ -0,0 +1,28 @@
# scopes.core.environ
"""Defining an environment for the communication system"""
from scopes import core
from scopes.core import actor
from scopes.core.message import Message
from scopes.util import keywords as k
def create_env(meta_env: actor.Mailbox) -> actor.Mailbox:
scope = core.create_scope(meta_env, (k.env, k.c00),
program=core.gen_program({}, default=env_proc()))
return core.neuron(scope)
def env_proc() -> core.Proc:
return core.gen_proc(env_actions(), default=None, finalize=core.notify())
def handle_created() -> core.Handler:
async def handle(msg: Message, scope: core.Scope) -> Message:
return msg
return handle
def env_actions() -> core.Actions:
return {
k.created: handle_created(),
}

20
scopes/core/message.py Normal file
View file

@ -0,0 +1,20 @@
# scopes.core.message
"""Generic message definition"""
from scopes import shape
class Message(shape.Record):
headFields: tuple[str, ...] = ('domain', 'action', 'class', 'item')
headFieldsAsKeywords = {'domain', 'action', 'class'}
customer = None
def addr(self):
hv = self.headValues
return hv[0], hv[2], hv[3]
def categ(self):
return self.domain, self.head['class']

View file

@ -8,6 +8,5 @@ from scopes.storage.concept import Concept
class Task(Concept):
headFields = ['name']
prefix = 'tsk'

68
scopes/shape.py Normal file
View file

@ -0,0 +1,68 @@
# scopes.shape
"""really abstract basic data shape definitions"""
from scopes.util import keywords as k
class Record:
headFields: tuple[str, ...] = () # to be defined by subclass
headFieldsAsKeywords: set[str] = set()
defaultHeadValue = ''
def __init__(self, *headValues, data=None, **kw):
self.head = dict((k, self.defaultHeadValue) for k in self.headFields)
for k, v in kw.items():
self.set(k, v)
for ix, hv in enumerate(headValues):
self.set(self.headFields[ix], hv)
self.data = data or {}
def __getattr__(self, k):
raise AttributeError(k)
@property
def headValues(self):
return [self.head[k] for k in self.headFields]
def set(self, attr, value):
if attr in self.headFields:
if value is None:
value = self.defaultHeadValue
elif attr in self.headFieldsAsKeywords:
value = k[value]
self.head[attr] = value
setattr(self, attr, value)
else:
raise AttributeError(attr)
def update(self, data, overwrite=False):
if data is None:
return
if overwrite:
self.data = data
else:
self.data.update(data)
def __repr__(self):
return f'<{self.__class__.__name__}: {self.headValues}, {self.data}>'
def asDict(self, flatten=False):
if flatten:
return self.head | self.data
return dict(head=self.head, data=self.data)
def toObject(self, cls):
return cls(**self.asDict(flatten=True))
@staticmethod
def prepareData(data, obj):
return data
@classmethod
def fromObject(cls, obj):
inp = obj.model_dump()
headValues = [inp.pop(hf) for hf in cls.headFields]
data = cls.prepareData(inp, obj)
return cls(*headValues, data=data)

View file

@ -7,6 +7,7 @@ from sqlalchemy import create_engine, MetaData, text
from sqlalchemy import Integer
from sqlalchemy.dialects.sqlite import JSON
import threading
from typing import Any
class Storage(object):
@ -85,7 +86,7 @@ class StorageFactory:
conn.commit()
IdType = Integer
JsonType = JSON
JsonType: Any = JSON
storageClass = Storage
@ -101,7 +102,7 @@ class StorageFactory:
# store information about container implementations, identified by a uid prefix.
registry = {}
registry: dict[str, Any] = {}
def registerContainerClass(cls):
prefix = cls.itemFactory.prefix

View file

@ -2,17 +2,17 @@
"""Core classes for concept map structure."""
from zope.interface import implementer
from zope.interface import implementer # type: ignore
from scopes.interfaces import IContainer
from scopes.storage.common import registerContainerClass, registry
from scopes.storage.tracking import Container, Track
from scopes.storage import tracking
defaultPredicate = 'standard'
class Concept(Track):
class Concept(tracking.Track):
headFields = ['name']
headFields: tuple[str, ...] = ('name',)
def parents(self, predicate=None):
return (r.getFirst() for r in self.parentRels(predicate))
@ -34,17 +34,17 @@ class Concept(Track):
rels.save(Triple(self.uid, child.uid, predicate))
class Concepts(Container):
class Concepts(tracking.Container):
insertOnChange = False
indexes = None
indexes: tracking.Indexes = None
def queryRels(self, **crit):
#pred = crit.get(predicate)
#if pred is not None and isinstance(pred, ('string', 'bytes')):
# crit['predicate'] = self.storage.getContainer(Predicate).queryLast(name=pred)
for k, v in crit.items():
if isinstance(v, Track):
if isinstance(v, tracking.Track):
crit[k] = v.uid
rels = self.storage.getContainer(Triple)
return rels.query(**crit)
@ -69,9 +69,9 @@ def storePredicate(storage, name):
preds.save(Predicate(name))
class Triple(Track):
class Triple(tracking.Track):
headFields = ['first', 'second', 'predicate']
headFields = ('first', 'second', 'predicate')
prefix = 'rel'
def getFirst(self):
@ -85,7 +85,7 @@ class Triple(Track):
@registerContainerClass
class Rels(Container):
class Rels(tracking.Container):
itemFactory = Triple
indexes = [('first', 'predicate', 'second'),
@ -99,7 +99,7 @@ class Rels(Container):
@implementer(IContainer)
class Type(Concept):
headFields = ['name', 'tprefix']
headFields = ('name', 'tprefix')
prefix = 'type'
@property

View file

@ -6,13 +6,13 @@ from sqlalchemy import create_engine
from sqlalchemy import BigInteger
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import scoped_session, sessionmaker
import transaction
from zope.sqlalchemy import register, mark_changed
import transaction # type: ignore
from zope.sqlalchemy import register, mark_changed # type: ignore
from scopes.storage.common import StorageFactory
from scopes.storage.common import StorageFactory as BaseStorageFactory
class StorageFactory(StorageFactory):
class StorageFactory(BaseStorageFactory):
def sessionFactory(self):
Session = scoped_session(sessionmaker(bind=self.engine, twophase=True))

View file

@ -1,6 +1,6 @@
# scopes.storage.folder
from zope.interface import implementer
from zope.interface import implementer # type: ignore
from scopes.interfaces import IContainer, IReference
from scopes.storage.common import registerContainerClass
@ -22,7 +22,7 @@ class DummyFolder(dict):
@implementer(IContainer, IReference)
class Folder(Track):
headFields = ['parent', 'name', 'ref']
headFields = ('parent', 'name', 'ref')
prefix = 'fldr'
def values(self):

View file

@ -2,20 +2,20 @@
"""Generic messages (or events) to be stored in SQL database."""
from scopes.core.message import Message as BaseMessage
from scopes.storage.common import registerContainerClass
from scopes.storage.tracking import Container, Track
from scopes.storage import tracking
class Message(Track):
class Message(BaseMessage, tracking.Track):
headFields = ['domain', 'action', 'class', 'item']
prefix = 'msg'
@registerContainerClass
class Messages(Container):
class Messages(tracking.Container):
itemFactory = Message
itemFactory: type[Message] = Message
indexes = [('domain', 'action', 'class', 'item'), ('domain', 'class', 'item')]
tableName = 'messages'
insertOnChange = True

View file

@ -6,7 +6,7 @@ in order to store attribute values in a SQL database.
This is currently in concept and exploration state.
"""
import transaction
import transaction # type: ignore
_not_found = object()

View file

@ -12,47 +12,32 @@ from sqlalchemy import Table, Column, Index
from sqlalchemy import DateTime, Text, func
from sqlalchemy import and_
from scopes import shape
from scopes.storage.common import registerContainerClass
type Indexes = list[tuple[str, ...]] | None
class Track:
class Record(shape.Record):
headFields = ['taskId', 'userName']
prefix = 'rec'
def __init__(self, *keys, data=None, timeStamp=None, trackId=None,
def __init__(self, *headValues, data=None, timeStamp=None, trackId=None,
container=None, **kw):
self.head = {}
for k, v in kw.items():
if k in self.headFields:
self.head[k] = kw.pop(k)
for ix, k in enumerate(keys):
self.head[self.headFields[ix]] = k
for k in self.headFields:
if self.head.get(k) is None:
self.head[k] = ''
setattr(self, k, self.head[k])
self.data = data or {}
super().__init__(*headValues, data=data, **kw)
self.timeStamp = timeStamp
self.trackId = trackId
self.container = container
def set(self, attr, value):
if attr in self.headFields:
if value is None:
value = ''
self.head[attr] = value
setattr(self, attr, value)
else:
raise AttributeError(attr)
def asDict(self, flatten=False):
ts = str(self.timeStamp)[:19]
base = dict(uid=self.uid, timeStamp=ts)
if flatten:
return base | self.head | self.data
return base | super().asDict()
def update(self, data, overwrite=False):
if data is None:
return
if overwrite:
self.data = data
else:
self.data.update(data)
@staticmethod
def prepareData(data, obj):
data.pop('uid', None)
data.pop('timeStamp', None)
return data
@property
def uid(self):
@ -66,13 +51,6 @@ class Track:
return ''
return str(self.trackId)
def __repr__(self):
return '<%s: %s>' % (self.__class__.__name__, self.asDict())
def asDict(self):
return dict(uid=self.uid, head=self.head, data=self.data,
timeStamp=str(self.timeStamp)[:19])
# shortcuts for compatibility with existing software:
def updateIndex(self, **kw):
@ -83,15 +61,20 @@ class Track:
def storeTrack(self):
self.container.update(self)
class Track(Record):
headFields: tuple[str, ...] = ('taskId', 'userName')
prefix = 'rec'
@registerContainerClass
class Container(object):
class Container:
itemFactory = Track
itemFactory: type[Track] = Track
tableName = 'tracks'
insertOnChange = True # always insert new track when data are changed
indexes = None # default, will be overwritten by registerContainerClass()
#indexes = [('username',), ('taskid', 'username')] # or put explicitly in class
indexes: Indexes = None # is set in registerContainerClass()
#indexes = [('username',), ('taskid', 'username')] # explicit setting in subclass
table = None
@ -137,21 +120,24 @@ class Container(object):
changed = False
if found.data != track.data:
found.update(track.data)
track.data = found.data
changed = True
if track.timeStamp is not None and found.timeStamp != track.timeStamp:
found.timeStamp = track.timeStamp
changed = True
if changed:
self.update(found)
self.update(found, not track.timeStamp)
track.trackId = found.trackId
track.timeStamp = found.timeStamp
return found.trackId
def insert(self, track, withTrackId=False):
t = self.table
values = self.setupValues(track, withTrackId)
stmt = t.insert().values(**values).returning(t.c.trackid)
trackId = self.session.execute(stmt).first()[0]
stmt = t.insert().values(**values).returning(t.c.trackid, t.c.timestamp)
trackId, ts = self.session.execute(stmt).first()
track.trackId = trackId
track.timeStamp = ts
self.storage.mark_changed()
return trackId

26
scopes/storage/user.py Normal file
View file

@ -0,0 +1,26 @@
# scopes.storage.user
from scopes.storage.common import registerContainerClass
from scopes.storage import concept
class User(concept.Concept):
headFields = ('name', 'login')
prefix = 'user'
@staticmethod
def prepareData(data, obj):
data = concept.Concept.prepareData(data, obj)
pw = data.pop('password', None)
if pw:
data['pw_hash'] = obj.hashPassword()
return data
@registerContainerClass
class Users(concept.Concepts):
itemFactory = User
indexes = [('name',), ('login',)]
tableName = 'users'

4
scopes/testing.py Normal file
View file

@ -0,0 +1,4 @@
# scopes.testing
"""Common stuff (config, fixtures, ...) for testing"""

View file

@ -1,29 +1,41 @@
# py-scopes/tests/config.py
import logging
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file='.env')
base_url: str = 'https://test.cy7.eu'
log_file: str = 'scopes/tests/log/scopes-test.log'
log_level: str = 'INFO'
log_format: str = '%(asctime)s %(levelname)s %(name)s %(message)s'
log_dateformat: str = '%Y-%m-%dT%H:%M:%S'
dbengine: str = 'postgresql+psycopg'
dbname: str = 'testdb'
dbuser: str = 'testuser'
dbpassword: str = 'secret'
dbschema: str = 'testing'
settings = Settings()
from os import getenv
import sys
#from scopes.web.app import demo_app, zope_app
base_url = settings.base_url
log_file = 'scopes/tests/log/scopes-test.log'
log_level = logging.INFO
log_format = '%(asctime)s %(levelname)s %(name)s %(message)s'
log_dateformat = '%Y-%m-%dT%H:%M:%S'
logging.basicConfig(filename=settings.log_file, level=settings.log_level,
format=settings.log_format, datefmt=settings.log_dateformat)
# server / app settings
server_port = '8999'
base_url = 'testing:'
#app = zope_app
# storage settings
# SQLite
dbengine = 'sqlite'
dbname = 'var/test.db'
dbuser = None
dbpassword = None
dbschema = None
# SQLite storage settings
dbengine = settings.dbengine
dbname = settings.dbname
dbuser = settings.dbuser
dbpassword = settings.dbpassword
dbschema = settings.dbschema
# special testing stuff
from scopes.tests import data_auth # add oidc URIs and keys to dummy_requests data

View file

@ -6,7 +6,7 @@ import os, sys
sys.path = [os.path.dirname(__file__)] + sys.path
import unittest
from scopes.tests import tlib_storage
from scopes.tests import tlib_storage, tlib_web
from scopes.tests.util import setup_logging
from scopes.storage.db.postgres import StorageFactory
@ -38,6 +38,12 @@ class Test(unittest.TestCase):
def test_005_message(self):
tlib_storage.test_message(self, config)
def test_013_web(self):
tlib_web.test_app(self, config)
tlib_web.test_auth(self, config)
tlib_web.test_user_data(self, config)
def test_suite():
return unittest.TestSuite((
unittest.TestLoader().loadTestsFromTestCase(Test),

View file

@ -4,6 +4,10 @@
from datetime import datetime
from scopes.storage import concept, folder, message, topic, tracking
from scopes.util import keywords as k
# predefine keywords used as domain, action, class head values in messages
k('session system')
def test_tracking(self, config):

View file

@ -42,7 +42,7 @@ def test_auth(self, config):
logger.info('test_auth keys: %s', keys)
def test_user_data(self, config):
from scopes.org import user
from scopes.web.auth import user
u1 = user.User(login='tst9', email='tst9@example.com',
firstName='Test', lastName='User')
xu1 = user.ExtUser(u1, idPrefix='test.')

View file

@ -3,9 +3,10 @@
import logging
def setup_logging(config):
settings = config.settings
hdlr = logging.getLogger().handlers[-1]
logging.getLogger().removeHandler(hdlr) # remove NullHandler added by testrunner
logging.basicConfig(filename=config.log_file, level=config.log_level,
format=config.log_format, datefmt=config.log_dateformat)
logging.basicConfig(filename=settings.log_file, level=settings.log_level,
format=settings.log_format, datefmt=settings.log_dateformat)

View file

@ -1,10 +1,53 @@
# scopes.util
import base64
from collections.abc import Iterable
import hashlib
from secrets import choice
import string
# symbols and keywords
class Symbol(str):
def __repr__(self):
return f'<{self}>'
class Symbols:
def __init__(self, ns=None):
self.__prefix = ns + '.' if ns else ''
def __call__(self, *keys):
for segment in keys:
for key in segment.split():
if not hasattr(self, key):
setattr(self, key, Symbol(self.__prefix + key))
return self
def __getattr__(self, k):
raise AttributeError(k)
def __getitem__(self, k):
return getattr(self, k)
keywords = Symbols()
# list / iterable utilities
def flatten(*it, ignore_none=False):
accu = []
def doit(it):
if ignore_none and it is None:
return
if isinstance(it, Iterable):
for t in it:
doit(t)
else:
accu.append(it)
doit(it)
return accu
# random strings, hashes, encodings
# for authentication, encryption, and other purposes

View file

@ -2,6 +2,9 @@
"""Basic user account (principal) definitions + access to identity provider."""
# should be moved to scopes.web.auth
# scopes.org: + person
from dataclasses import dataclass, field
from typing import List, Optional

24
tests/test.env Normal file
View file

@ -0,0 +1,24 @@
# tests/test.env
LOG_FILE=tests/log/scopes-test.log
BASE_URL=http://test
DBNAME=testdb
DBUSER=testuser
DBPASSWORD=secret
DBSCHEMA=testing
AUTH_METHOD=basic
# openssl rand -hex 32:
JWT_KEY=391a521ee5ba2b6ce67b8fdbb9f0ca88f40a87d3bc1e82974381b647bb0fc776
JWT_ALGORITHM=HS256
OIDC_PROVIDER=http://oidc.test
OIDC_CLIENT_ID=12345
OIDC_COOKIE_CRYPT=secret_crypt
OIDC_ORGANIZATION_ID=12346
OIDC_PROJECT_ID=12347

94
tests/test_api.py Normal file
View file

@ -0,0 +1,94 @@
# py-scopes: tests.test_api
"""Tests for the fastapi- (and asyncio-) based stuff"""
from scopes import config
from scopes.api.auth import config as auth_config
config.settings = config.Settings(_env_file='tests/test.env')
auth_config.settings = auth_config.Settings(_env_file='tests/test.env')
import pytest
from fastapi.testclient import TestClient
from httpx2 import ASGITransport, AsyncClient, BasicAuth
from scopes.api import main
from scopes.api.main import app
from scopes.storage import tracking, user as st_user
from scopes.api.auth import user as api_user
@pytest.fixture(scope='module')
def storage():
storage = config.StorageFactory(config.settings)(config.settings.dbschema)
storage.dropTable('tracks')
storage.dropTable('users')
storage.commit()
return storage
def async_client():
return AsyncClient(transport=ASGITransport(app=app), base_url='http://test')
#@pytest.mark.anyio
#async def test_root():
#async with async_client() as clt:
#response = await clt.get('/')
def test_root():
with TestClient(app) as clt:
response = clt.get('/')
assert response.status_code == 200
assert response.json() == {'Hello': 'World'}
def test_tracking(storage, caplog):
tracks = storage.create(tracking.Container)
tr01 = tracking.Track('t01', 'john')
tr01.update(dict(activity='testing'))
trid01 = tracks.save(tr01)
storage.commit()
with TestClient(app) as clt:
caplog.set_level(config.settings.log_level)
tracks = clt.get('/tracks').json()
assert len(tracks) == 1
tr = tracks[0]
assert tr['timeStamp'].startswith('202')
tr['timeStamp'] = '...'
assert tr == {'uid': 'rec-1', 'head': {'taskId': 't01', 'userName': 'john'},
'data': {'activity': 'testing'}, 'timeStamp': '...'}
resp = clt.post('/tracks', json={'head': {'taskId': 't02', 'userName': 'jim'},
'data': {'activity': 'implement post track'}})
assert resp.status_code == 200
tr02 = resp.json()
assert tr02['uid'] == 'rec-2'
#print('\n*** Logs: ', caplog.text)
#print('*** tr02', tr02)
def test_user(storage, caplog):
users = storage.create(st_user.Users)
pw = 'secret'
pw_hash = api_user.hashPassword(pw)
u01 = st_user.User('jim', 'jim', data=dict(display_name='Jim', pw_hash=pw_hash))
u01id = users.save(u01)
storage.commit()
with TestClient(app) as clt:
caplog.set_level(config.settings.log_level)
auth = BasicAuth(username='jim', password='secret')
users = clt.get('/users').json()
assert len(users) == 1
u01i = users[0]
assert u01i.get(pw_hash) is None
inp = {'name': 'jane', 'login': 'jane', 'display_name': 'Jane',
'email': 'jane@xmpl.org', 'password': 'secret'}
resp = clt.post('/users', json=inp, auth=auth)
assert resp.status_code == 200
u02 = resp.json()
assert u02['uid'] == 'user-2'
ts02 = u02.get('timeStamp')
assert ts02 is not None
del inp['password']
inp['display_name'] = 'Jane Doe'
resp = clt.post('/users', json=inp, auth=auth)
assert resp.status_code == 200
u02a = resp.json()
assert u02a['uid'] == 'user-2'
ts02a = u02a.get('timeStamp')
assert ts02a is not None

16
tests/test_config.py Normal file
View file

@ -0,0 +1,16 @@
# py-scopes: tests.test_config
"""Tests for scopes configuration settings via config.py and .env"""
import os
import pytest
from scopes import config
config.settings = config.Settings(_env_file='tests/test.env')
from scopes.config import settings
def test_config():
assert settings.dbname == 'testdb'
assert settings.dbschema == 'testing'

89
tests/test_core.py Normal file
View file

@ -0,0 +1,89 @@
# py-scopes: tests.test_core
""" Tests for scopes.core modules, including e.g. actor, message"""
import asyncio
import pytest
from scopes import core
from scopes.core import actor, environ
from scopes.core.message import Message
from scopes.util import keywords as k
k('data', 'hello', 'person', 'show', 'switch', 'quit', 'test')
@pytest.fixture
def collector():
return []
def show(msg, coll):
coll.append(msg)
def handle_basic(coll):
async def handle(msg):
match msg:
case k.show: show(msg, coll)
case k.switch: return handle_alt(coll)
case _: show(msg, coll)
return handle
def handle_alt(coll):
async def handle(msg):
match msg:
case k.show: show('switched', coll)
case _: await asyncio.sleep(0.01); show(msg, coll)
return handle
@pytest.mark.anyio
async def test_actor(collector):
mb1 = actor.create(handle_basic(collector))
mb2 = actor.create(handle_basic(collector))
msgs = [Message(k.test, k.hello), k.show, k.switch, k.show, "actor 1: Goodbye"]
await asyncio.gather(*[actor.send(mb1, m) for m in msgs])
await actor.send(mb2, "actor 2")
await actor.send(mb2, k.show)
await actor.send(mb2, k.quit)
await actor.send(mb2, k.quit)
await asyncio.sleep(0.01)
#print(collector)
assert len(collector) == 6
assert collector[-1] == 'actor 1: Goodbye'
def test_message():
msg = Message(k.test, k.data, 'person', '4711',
data=dict(firstName='Bob', lastName='Miller'))
assert msg.head['class'] is k.person
assert msg.addr() == (k.test, k.person, '4711')
assert msg.categ() == (k.test, k.person)
assert msg.asDict() == {'head': {'domain': k.test, 'action': k.data,
'class': k.person, 'item': '4711'},
'data': {'firstName': 'Bob', 'lastName': 'Miller'}}
assert msg.asDict(flatten=True) == {
'domain': k.test, 'action': k.data, 'class': k.person, 'item': '4711',
'firstName': 'Bob', 'lastName': 'Miller'}
@pytest.fixture
async def meta_env(collector):
async def collect(msg):
collector.append(msg)
return actor.create(collect)
@pytest.mark.anyio
async def test_core(meta_env, collector):
env = environ.create_env(meta_env)
zero = await core.create_zero(env)
await actor.send(env, Message(k.env, k.hello))
await actor.send(zero, Message(k.csys, k.hello))
await actor.send(zero, Message(k.csys, k.create,
data={k.connect: k.pred, k.address: (1, 1)}))
await asyncio.sleep(0.01)
assert len(collector) == 4
assert collector[0].action is k.created
assert collector[1].domain is k.env
assert collector[2].domain is k.csys
assert collector[3].action is k.created
#print(collector)

130
tests/test_storage.py Normal file
View file

@ -0,0 +1,130 @@
# py-scopes: tests.test_storage
""" Tests for the (PostgreSQL) storage-related functionalities"""
from scopes import config
config.settings = config.Settings(_env_file='tests/test.env')
from datetime import datetime
import pytest
from scopes.storage import concept, folder, message, topic, tracking
from scopes.util import keywords as k
# predefined keywords used as domain, action, class head values in messages
k('session system')
@pytest.fixture(scope='module')
def storage():
storage = config.StorageFactory(config.settings)(config.settings.dbschema)
storage.dropTable('tracks')
storage.dropTable('folders')
storage.dropTable('types')
storage.dropTable('preds')
storage.dropTable('rels')
storage.dropTable('topics')
storage.dropTable('messages')
storage.commit()
return storage
def test_tracking(storage):
tracks = storage.create(tracking.Container)
tr01 = tracking.Track('t01', 'john')
assert tr01.head == {'taskId': 't01', 'userName': 'john'}
assert tr01.taskId == 't01'
assert tr01.userName == 'john'
tr01.update(dict(activity='testing'))
trid01 = tracks.save(tr01)
assert trid01 > 0
tr01a = tracks[f'{trid01:07}']
assert tr01a.head == tr01.head
assert tr01a.trackId == trid01
assert tr01a.data.get('activity') == 'testing'
tr01a.update(dict(text='Set up unit tests.'))
tr01a.timeStamp = None
assert tracks.save(tr01a) > 0
tr01b = tracks.queryLast(taskId='t01')
assert tr01b.head == tr01.head
assert tr01b.trackId != trid01
assert tr01b.data.get('activity') == 'testing'
assert tr01b.data.get('text') == 'Set up unit tests.'
tr02 = tracking.Track('t02', 'jim', trackId=31, timeStamp=datetime(2023, 11, 30),
data=dict(activity='concept'))
trid02 = tracks.upsert(tr02)
assert trid02 == 31
assert tr02.uid == 'rec-31'
tr02.trackId = trid01
trid021 = tracks.upsert(tr02)
assert trid021 == trid01
assert tr02.uid == 'rec-' + str(trid01)
tr03 = storage.getItem('rec-31')
assert tr03.trackId == 31
n = tracks.remove(tr03)
assert n == 1
assert tracks.get(31) == None
storage.commit()
def test_folder(storage):
root = folder.Root(storage)
assert list(root.keys()) == []
root['top'] = folder.Folder()
assert list(root.keys()) == ['top']
top = root['top']
top['child1'] = folder.Folder(data=dict(title='First Child'))
assert list(top.keys()) == ['child1']
ch1 = top['child1']
assert ch1.parent == top.rid
assert list(top.keys()) == ['child1']
ch1.set('name', 'level2-item1')
ch1.storeTrack()
storage.commit()
def test_type(storage):
concept.setupCoreTypes(storage)
types = storage.getContainer(concept.Type)
tps = list(types.query())
assert len(tps) == 8
tfolder = types.queryLast(name='folder')
fldrs = list(tfolder.values())
assert len(fldrs) == 2
assert fldrs[0].name == 'top'
storage.commit()
def test_topic(storage):
rels = storage.getContainer(concept.Triple)
topics = storage.getContainer(topic.Topic)
types = storage.getContainer(concept.Type)
concept.storePredicate(storage, concept.defaultPredicate)
root = folder.Root(storage)
root['top']['topics'] = ftopics = folder.Folder()
ttopic = types.queryLast(name='topic')
assert ttopic.name == 'topic'
ftopics.setTarget(ttopic)
assert ftopics.getTarget().name == 'topic'
tp_itc = topic.Topic('itc', data=dict(
title='ITC', description='Information and Communication Technology'))
topics.save(tp_itc)
tp_proglang = topic.Topic('prog_lang', data=dict(
title='Programming Languages',
description='Programming Languages'))
topics.save(tp_proglang)
tp_itc.addChild(tp_proglang)
c = list(tp_itc.children())
assert c[0].name == 'prog_lang'
storage.commit()
def test_message(storage):
messages = storage.create(message.Messages)
m01 = message.Message('system', 'data', 'session', 'V1_317784226621611853')
m01.update(dict(userid='tst9'))
mid01 = messages.save(m01)
msgs = list(messages.query())
assert msgs[0].domain is k.system
assert len(msgs) == 1
storage.commit()

30
tests/test_util.py Normal file
View file

@ -0,0 +1,30 @@
# py-scopes: tests.test_util
"""Tests for the scopes.util package"""
import pytest
from scopes.util import keywords as k, flatten, Symbols
k('dummy other', 'cd', 'new', 'erp')
dom = Symbols('dom')('crm erp sys')
def test_keywords():
assert k.dummy == 'dummy'
assert k.dummy is not 'dummy'
assert k.dummy is k.dummy
assert k.dummy is not k.other
assert getattr(k, "abcd"[-2:]) is k.cd
assert k['dummy'] is k.dummy
assert k['new'] is k['new']
assert dom.erp == 'dom.erp'
assert dom.erp is not 'dom.erp'
assert repr(dom.erp) == '<dom.erp>'
assert dom.erp != k.erp
def test_flatten():
lst = (1, (None, 2), (3, (4, 5)))
assert flatten(lst) == [1, None, 2, 3, 4, 5]
assert flatten(42, lst, (6, 7), ignore_none=True) == [42, 1, 2, 3, 4, 5, 6, 7]