xplan-tools 1.11.1__py3-none-any.whl → 1.12.1__py3-none-any.whl

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.
@@ -6,10 +6,20 @@ from pathlib import Path
6
6
  from typing import Iterable
7
7
 
8
8
  from alembic import command, config, script
9
- from alembic.runtime import migration
10
9
  from geoalchemy2 import load_spatialite_gpkg
11
10
  from geoalchemy2.admin.dialects.sqlite import load_spatialite_driver
12
- from sqlalchemy import DDL, Column, Engine, create_engine, inspect, text
11
+ from sqlalchemy import (
12
+ Column,
13
+ Engine,
14
+ MetaData,
15
+ Table,
16
+ create_engine,
17
+ delete,
18
+ insert,
19
+ inspect,
20
+ select,
21
+ text,
22
+ )
13
23
  from sqlalchemy.engine import URL, make_url
14
24
 
15
25
  # from sqlalchemy.dialects.sqlite.base import SQLiteCompiler
@@ -21,7 +31,7 @@ from sqlalchemy.orm import sessionmaker
21
31
  # from sqlalchemy.sql.expression import BindParameter
22
32
  from xplan_tools.model import model_factory
23
33
  from xplan_tools.model.base import BaseCollection, BaseFeature
24
- from xplan_tools.model.orm import Base, Feature, Geometry
34
+ from xplan_tools.model.orm import Base, Feature, Geometry, Refs
25
35
  from xplan_tools.util import check_schema_accessibility
26
36
 
27
37
  # from xplan_tools.util import linearize_geom
@@ -62,147 +72,124 @@ class DBRepository(BaseRepository):
62
72
  self.srid = srid
63
73
  self.with_views = with_views
64
74
 
65
- alembic_cfg = config.Config()
66
- alembic_cfg.set_main_option("script_location", "xplan_tools:model:migrations")
67
- alembic_cfg.set_main_option(
75
+ self.alembic_cfg = config.Config()
76
+ self.alembic_cfg.set_main_option(
77
+ "script_location", "xplan_tools:model:migrations"
78
+ )
79
+ self.alembic_cfg.set_main_option("srid", str(srid))
80
+ if with_views:
81
+ self.alembic_cfg.set_main_option("with_views", "1")
82
+ self.alembic_cfg.set_main_option(
68
83
  "sqlalchemy.url",
69
84
  datasource.replace("gpkg:", "sqlite:").replace(
70
85
  "postgresql:", "postgresql+psycopg:"
71
86
  ),
72
87
  )
73
- alembic_url = make_url(alembic_cfg.get_main_option("sqlalchemy.url"))
74
88
  if self.schema and self.dialect == "postgresql":
75
- alembic_cfg.set_main_option("custom_schema", self.schema)
76
- current_version = script.ScriptDirectory.from_config(alembic_cfg).get_heads()
77
- alembic_engine = create_engine(alembic_url)
89
+ check_schema_accessibility(self._engine, self.schema)
90
+ self.alembic_cfg.set_main_option("custom_schema", self.schema)
91
+ current_version = script.ScriptDirectory.from_config(
92
+ self.alembic_cfg
93
+ ).get_heads()
78
94
  # test for tables and revision
79
- with alembic_engine.connect() as conn:
80
- context = migration.MigrationContext.configure(
81
- conn,
82
- )
83
- db_version = context.get_current_heads()
95
+ with self._engine.connect() as conn:
84
96
  inspector = inspect(conn)
85
97
  tables = inspector.get_table_names(schema=self.schema)
86
- is_coretable = {"coretable", "refs"}.issubset(set(tables))
87
- is_current_version = set(db_version) == set(current_version)
88
-
89
- # handle schema upgrade or table creation
90
- if db_version:
91
- if not is_current_version:
92
- if self.dialect == "postgresql":
93
- logger.info("Running database migrations")
94
- command.upgrade(alembic_cfg, "head")
95
- else:
96
- e = RuntimeError(
97
- f"Incompatible database revision and automatic migration not implemented for {self.dialect}"
98
- )
99
- e.add_note(
100
- "please set up a new database with the current version of this library"
101
- )
102
- raise e
98
+ is_coretable = {"coretable", "refs"}.issubset(set(tables))
99
+ if "alembic_version" in tables:
100
+ alembic_table = Table(
101
+ "alembic_version",
102
+ MetaData(schema=self.schema),
103
+ Column("version_num"),
104
+ )
105
+ stmt = select(alembic_table.c.version_num)
106
+ db_version = conn.execute(stmt).scalars().all()
103
107
  else:
108
+ db_version = []
109
+ is_current_version = set(db_version) == set(current_version)
110
+ if is_current_version:
104
111
  logger.info("Database is at current revision")
112
+ return
113
+ # handle schema upgrade or table creation
114
+ if is_coretable and not db_version:
115
+ e = RuntimeError("Coretable with no revision found in database")
116
+ e.add_note(
117
+ "it is likely that the database was set up with an older version of this library which didn't use revisions yet"
118
+ )
119
+ e.add_note(
120
+ "please set up a new database or add a revision corresponding to the current model manually"
121
+ )
122
+ raise e
123
+ # if postgresql, run alembic and return
124
+ elif self.dialect == "postgresql":
125
+ logger.info(
126
+ "Running database migrations"
127
+ if db_version
128
+ else "Creating new database schema"
129
+ )
130
+ command.upgrade(self.alembic_cfg, "head")
131
+ return
132
+ elif db_version:
133
+ e = NotImplementedError(
134
+ f"Incompatible database revision and automatic migration not implemented for {self.dialect}"
135
+ )
136
+ e.add_note(
137
+ "please set up a new database with the current version of this library"
138
+ )
139
+ raise e
105
140
  else:
106
- if is_coretable:
107
- e = RuntimeError("Coretable with no revision found in database")
108
- e.add_note(
109
- "it is likely that the database was set up with an older version of this library which didn't use revisions yet"
110
- )
111
- e.add_note(
112
- "please set up a new database or add a revision corresponding to the current model manually"
113
- )
114
- raise e
115
- else:
116
- # create tables if it's a fresh DB and set it to current revision
117
- logger.info("Creating new database schema")
118
- self.create_tables(self.srid, self.with_views)
119
- command.stamp(alembic_cfg, "head")
141
+ # create tables if it's a fresh file-based DB and set it to current revision
142
+ logger.info("Creating new database schema")
143
+ self.create_tables(self.srid)
144
+ command.stamp(self.alembic_cfg, "head")
120
145
 
121
146
  @property
122
147
  def _engine(self) -> Engine:
148
+ url = (
149
+ self.datasource.set(drivername="postgresql+psycopg")
150
+ if self.dialect == "postgresql"
151
+ else self.datasource
152
+ )
153
+ connect_args: dict[str, str] = {}
154
+ if self.schema and self.dialect == "postgresql":
155
+ connect_args["options"] = f"-csearch_path={self.schema},public"
156
+ engine = create_engine(url, connect_args=connect_args)
123
157
  if self.dialect == "geopackage":
124
- engine = create_engine(self.datasource)
125
158
  listen(engine, "connect", load_spatialite_gpkg)
126
- return engine
127
159
  elif self.dialect == "sqlite":
128
- engine = create_engine(self.datasource)
129
160
  listen(
130
161
  engine,
131
162
  "connect",
132
163
  load_spatialite_driver,
133
164
  )
134
- return engine
135
- else:
136
- engine = create_engine(
137
- self.datasource.set(drivername="postgresql+psycopg"), echo=False
138
- )
139
- if self.schema:
140
- check_schema_accessibility(engine, self.schema)
141
-
142
- return engine
143
-
144
- # see https://docs.sqlalchemy.org/en/20/faq/sqlexpressions.html#rendering-bound-parameters-inline
145
- # @compiles(BindParameter)
146
- # def _render_literal_bindparam(
147
- # element: BindParameter, compiler, dump_to_file=False, **kw
148
- # ):
149
- # if not dump_to_file:
150
- # return compiler.visit_bindparam(element, **kw)
151
- # if (
152
- # isinstance(compiler, SQLiteCompiler)
153
- # and "geometry" in str(element.type)
154
- # and element.value is not None
155
- # ):
156
- # return repr(linearize_geom(element.value))
157
- # elif isinstance(element.value, dict):
158
- # return repr(json.dumps(element.value))
159
- # else:
160
- # return repr(str(element.value))
165
+ return engine
161
166
 
162
167
  def get_plan_by_id(self, id: str) -> BaseCollection:
163
168
  logger.debug(f"retrieving plan with id {id}")
164
169
  with self.Session() as session:
165
- stmt = text(
166
- "SELECT srs_id FROM gpkg_geometry_columns WHERE table_name='coretable'"
167
- if self.dialect == "geopackage"
168
- else "SELECT srid FROM geometry_columns WHERE f_table_name='coretable'"
169
- )
170
- srid = session.execute(stmt).scalar_one()
171
-
172
- feature = session.get(Feature, id)
173
- if not feature:
170
+ plan_feature = session.get(Feature, id)
171
+ if not plan_feature:
174
172
  raise ValueError(f"no feature found with id {id}")
175
- elif "Plan" not in feature.featuretype:
176
- raise ValueError(f"{feature.featuretype} is not a plan object")
173
+ elif "Plan" not in plan_feature.featuretype:
174
+ raise ValueError(f"{plan_feature.featuretype} is not a plan object")
177
175
  else:
178
- self.version = (
179
- "2.0" if feature.appschema == "xtrasse" else feature.version
180
- )
181
- collection = {
182
- id: model_factory(
183
- feature.featuretype, self.version, feature.appschema
176
+ plan_model = model_factory(
177
+ plan_feature.featuretype,
178
+ plan_feature.version,
179
+ plan_feature.appschema,
180
+ ).model_validate(plan_feature)
181
+ collection = {id: plan_model}
182
+ srid = plan_model.get_geom_srid()
183
+ # iterate related features with depth=2: plan -> section -> features
184
+ for feature in plan_feature.related_features(session, depth=2):
185
+ collection[str(feature.id)] = model_factory(
186
+ feature.featuretype, feature.version, feature.appschema
184
187
  ).model_validate(feature)
185
- }
186
- for ref in feature.refs:
187
- collection[ref.feature_inv.id] = model_factory(
188
- ref.feature_inv.featuretype, self.version, feature.appschema
189
- ).model_validate(ref.feature_inv)
190
- if ref.rel == "bereich":
191
- for obj in ref.feature_inv.refs:
192
- collection[obj.feature_inv.id] = model_factory(
193
- obj.feature_inv.featuretype,
194
- self.version,
195
- feature.appschema,
196
- ).model_validate(obj.feature_inv)
197
- for ref_inv in feature.refs_inv:
198
- collection[ref_inv.feature.id] = model_factory(
199
- ref_inv.feature.featuretype, self.version, feature.appschema
200
- ).model_validate(ref_inv.feature)
201
188
  return BaseCollection(
202
189
  features=collection,
203
190
  srid=srid,
204
- version=feature.version,
205
- appschema=feature.appschema,
191
+ version=plan_feature.version,
192
+ appschema=plan_feature.appschema,
206
193
  )
207
194
 
208
195
  def get(self, id: str) -> BaseFeature:
@@ -228,24 +215,25 @@ class DBRepository(BaseRepository):
228
215
  def delete_plan_by_id(self, id: str) -> BaseFeature:
229
216
  logger.debug(f"deleting plan with id {id}")
230
217
  with self.Session() as session:
231
- feature = session.get(Feature, id)
232
- if not feature:
218
+ plan_feature = session.get(Feature, id)
219
+ if not plan_feature:
233
220
  raise ValueError(f"no feature found with id {id}")
234
- elif "Plan" not in feature.featuretype:
235
- raise ValueError(f"{feature.featuretype} is not a plan object")
221
+ elif "Plan" not in plan_feature.featuretype:
222
+ raise ValueError(f"{plan_feature.featuretype} is not a plan object")
236
223
  else:
237
- for ref in feature.refs:
238
- if ref.rel == "bereich":
239
- for bereich_ref in ref.feature_inv.refs:
240
- session.delete(bereich_ref.feature_inv)
241
- session.delete(ref.feature_inv)
242
- for ref_inv in feature.refs_inv:
243
- session.delete(ref_inv.feature)
244
- session.delete(feature)
224
+ plan_model = model_factory(
225
+ plan_feature.featuretype,
226
+ plan_feature.version,
227
+ plan_feature.appschema,
228
+ ).model_validate(plan_feature)
229
+ ids = [plan_feature.id]
230
+ ids += [
231
+ feature.id for feature in plan_feature.related_features(session)
232
+ ]
233
+ stmt = delete(Feature).where(Feature.id.in_(ids))
234
+ session.execute(stmt)
245
235
  session.commit()
246
- return model_factory(
247
- feature.featuretype, feature.version, feature.appschema
248
- ).model_validate(feature)
236
+ return plan_model
249
237
 
250
238
  def delete(self, id: str) -> BaseFeature:
251
239
  logger.debug(f"deleting feature with id {id}")
@@ -265,15 +253,20 @@ class DBRepository(BaseRepository):
265
253
  ) -> None:
266
254
  logger.debug("saving collection")
267
255
  with self.Session() as session:
256
+ feature_list = []
257
+ refs_list = []
268
258
  for feature in (
269
259
  features.get_features()
270
260
  if isinstance(features, BaseCollection)
271
261
  else features
272
262
  ):
273
- feature = feature.model_dump_coretable()
274
- if session.get(Feature, feature.id):
275
- raise ValueError(f"feature with id {feature.id} already exists")
276
- session.merge(feature)
263
+ feature, refs = feature.model_dump_coretable_bulk()
264
+ feature_list.append(feature)
265
+ refs_list.extend([ref for ref in refs if ref not in refs_list])
266
+ if feature_list:
267
+ session.execute(insert(Feature), feature_list)
268
+ if refs_list:
269
+ session.execute(insert(Refs), refs_list)
277
270
  session.commit()
278
271
 
279
272
  def update_all(
@@ -322,32 +315,21 @@ class DBRepository(BaseRepository):
322
315
  else:
323
316
  raise ValueError(f"no feature found with id {id}")
324
317
 
325
- def create_tables(self, srid: int, with_views: bool = False) -> None:
318
+ def create_tables(self, srid: int) -> None:
326
319
  """Creates coretable and related/spatial tables in the database.
327
320
 
328
321
  Args:
329
322
  srid: the EPSG code for spatial data
330
- with_views: whether to create geometrytype-specific views (postgres only)
331
323
  """
332
324
 
333
325
  @listens_for(Base.metadata, "before_create")
334
- def pre_creation(metadata, conn, **kw):
326
+ def pre_creation(_, conn, **kwargs):
335
327
  if self.dialect == "sqlite":
336
328
  conn.execute(text("SELECT InitSpatialMetaData('EMPTY')"))
337
329
  conn.execute(text("SELECT InsertEpsgSrid(:srid)"), {"srid": srid})
338
330
 
339
331
  @listens_for(Base.metadata, "after_create")
340
- def post_creation(metadata, conn, **kw):
341
- coretable = self.schema + ".coretable" if self.schema else "coretable"
342
- stmt = (
343
- DDL(
344
- "CREATE INDEX IF NOT EXISTS idx_coretable_geometry ON %(coretable)s USING GIST (geometry)",
345
- {"coretable": coretable},
346
- )
347
- if self.dialect == "postgresql"
348
- else text("SELECT CreateSpatialIndex(:coretable, 'geometry')")
349
- )
350
- conn.execute(stmt, {"coretable": coretable})
332
+ def post_creation(_, conn, **kwargs):
351
333
  if self.dialect == "geopackage":
352
334
  conn.execute(
353
335
  text(
@@ -362,83 +344,40 @@ class DBRepository(BaseRepository):
362
344
  )
363
345
  )
364
346
  conn.execute(
365
- text("""
366
- INSERT INTO gpkgext_relations (base_table_name, base_primary_column, related_table_name, related_primary_column, relation_name, mapping_table_name)
367
- VALUES
368
- ('coretable', 'id', 'coretable', 'id', 'features', 'refs')
369
- """)
347
+ text(
348
+ """
349
+ INSERT INTO gpkgext_relations (base_table_name, base_primary_column, related_table_name, related_primary_column, relation_name, mapping_table_name)
350
+ VALUES
351
+ ('coretable', 'id', 'coretable', 'id', 'features', 'refs')
352
+ """
353
+ )
370
354
  )
371
355
  conn.execute(
372
- text("""
373
- INSERT INTO gpkg_data_columns (table_name, column_name, mime_type)
374
- VALUES
375
- ('coretable', 'properties', 'application/json')
376
- """)
377
- )
378
- if with_views:
379
- if self.dialect != "postgresql":
380
- logger.warning(
381
- f"Creating views not yet supported for {self.dialect}, skipping"
382
- )
383
- else:
384
- conn.execute(
385
- DDL(
386
- """
387
- create or replace view %(schema)s.coretable_points as
388
- select pk, id, featuretype, properties, ST_Multi(geometry)::geometry(MultiPoint, %(srid)s) as geometry, appschema, version
389
- from %(schema)s.coretable
390
- where geometry_type = 'point'
391
- """,
392
- {"srid": srid, "schema": self.schema or "public"},
393
- )
394
- )
395
- conn.execute(
396
- DDL(
397
- """
398
- create or replace view %(schema)s.coretable_lines as
399
- select pk, id, featuretype, properties, ST_Multi(ST_ForceCurve(geometry))::geometry(MultiCurve, %(srid)s) as geometry, appschema, version
400
- from %(schema)s.coretable
401
- where geometry_type = 'line'
402
- """,
403
- {"srid": srid, "schema": self.schema or "public"},
404
- )
405
- )
406
- conn.execute(
407
- DDL(
408
- """
409
- create or replace view %(schema)s.coretable_polygons as
410
- select pk, id, featuretype, properties, ST_Multi(ST_ForceCurve(geometry))::geometry(MultiSurface, %(srid)s) as geometry, appschema, version
411
- from %(schema)s.coretable
412
- where geometry_type = 'polygon'
413
- """,
414
- {"srid": srid, "schema": self.schema or "public"},
415
- )
416
- )
417
- conn.execute(
418
- DDL(
419
- """
420
- create or replace view %(schema)s.coretable_nogeoms as
421
- select pk, id, featuretype, properties, geometry, appschema, version
422
- from %(schema)s.coretable
423
- where geometry_type = 'nogeom'
424
- """,
425
- {"schema": self.schema or "public"},
426
- ),
356
+ text(
357
+ """
358
+ INSERT INTO gpkg_data_columns (table_name, column_name, mime_type)
359
+ VALUES
360
+ ('coretable', 'properties', 'application/json')
361
+ """
427
362
  )
363
+ )
428
364
 
429
365
  logger.debug(f"creating tables with srid {srid}")
430
366
  tables = Base.metadata.sorted_tables
431
367
  if not self.dialect == "geopackage":
432
368
  tables.pop(1)
433
369
  tables[0].append_column(
434
- Column("geometry", Geometry(srid=srid, spatial_index=False), nullable=True),
370
+ Column(
371
+ "geometry",
372
+ Geometry(
373
+ srid=srid,
374
+ spatial_index=True,
375
+ ),
376
+ nullable=True,
377
+ ),
435
378
  replace_existing=True,
436
379
  )
437
380
 
438
- if self.schema and self.dialect.startswith("postgresql"):
439
- for table in Base.metadata.tables.values():
440
- table.schema = self.schema
441
-
442
381
  try:
443
382
  Base.metadata.create_all(self._engine, tables)
444
383
  remove(Base.metadata, "before_create", pre_creation)
@@ -453,7 +392,7 @@ class DBRepository(BaseRepository):
453
392
  def delete_tables(self) -> None:
454
393
  """Deletes coretable and related/spatial tables from the database."""
455
394
  logger.debug("deleting tables")
456
- if self.schema and self.dialect.startswith("postgresql"):
457
- for table in Base.metadata.tables.values():
458
- table.schema = self.schema
459
- Base.metadata.drop_all(self._engine)
395
+ if self.dialect == "postgresql":
396
+ command.downgrade(self.alembic_cfg, "base")
397
+ else:
398
+ Base.metadata.drop_all(self._engine)
@@ -199,9 +199,7 @@ class GMLRepository(BaseRepository):
199
199
  if self.appschema == "xtrasse"
200
200
  else "{http://www.opengis.net/wfs/2.0}member",
201
201
  ).append(
202
- feature.model_dump_gml(
203
- self.appschema, feature_srs=kwargs.get("feature_srs", True)
204
- )
202
+ feature.model_dump_gml(feature_srs=kwargs.get("feature_srs", True))
205
203
  )
206
204
  bbox = get_envelope(geoms)
207
205
  attrib = (
xplan_tools/main.py CHANGED
@@ -22,6 +22,8 @@ __version__ = metadata.version("xplan_tools")
22
22
  console = Console()
23
23
  error_console = Console(stderr=True, style="bold red")
24
24
  logger = logging.getLogger(__name__)
25
+ # don't propagate alembic logs when using CLI
26
+ logging.getLogger("alembic").propagate = False
25
27
 
26
28
  app = typer.Typer(help=f"XPlan-Tools {__version__}")
27
29
  db_app = typer.Typer()
@@ -8,7 +8,9 @@ from xplan_tools.model.orm import Feature, Refs
8
8
  class CoretableAdapter:
9
9
  """Class to add ORM model - i.e. coretable - transformation methods to XPlan pydantic model via inheritance."""
10
10
 
11
- def _to_coretable(self) -> Feature:
11
+ def _to_coretable(
12
+ self, bulk_mode: bool = False
13
+ ) -> Feature | tuple[dict, list[dict]]:
12
14
  """Converts a BaseFeature to a Coretable Feature object."""
13
15
  properties = self.model_dump(mode="json", exclude_none=True)
14
16
  id = properties.pop("id")
@@ -95,19 +97,23 @@ class CoretableAdapter:
95
97
  gener_att[f"wert_{item.get_name()}"] = gener_att.pop("wert")
96
98
  gener_att["datatype"] = item.get_name()
97
99
  properties["hatGenerAttribut"].append(gener_att)
98
- feature = Feature(
99
- id=id,
100
- featuretype=self.get_name(),
101
- properties=properties,
102
- geometry=geometry,
103
- appschema=self.get_appschema(),
104
- version=self.get_version(),
105
- )
106
- if refs:
107
- feature.refs = [Refs(**ref) for ref in refs]
108
- if refs_inv:
109
- feature.refs_inv = [Refs(**ref) for ref in refs_inv]
110
- return feature
100
+ feature = {
101
+ "id": id,
102
+ "featuretype": self.get_name(),
103
+ "properties": properties,
104
+ "geometry": geometry,
105
+ "appschema": self.get_appschema(),
106
+ "version": self.get_version(),
107
+ }
108
+ if bulk_mode:
109
+ return feature, [*refs, *refs_inv]
110
+ else:
111
+ orm_feature = Feature(**feature)
112
+ if refs:
113
+ orm_feature.refs = [Refs(**ref) for ref in refs]
114
+ if refs_inv:
115
+ orm_feature.refs_inv = [Refs(**ref) for ref in refs_inv]
116
+ return orm_feature
111
117
 
112
118
  @classmethod
113
119
  def _from_coretable(cls, feature: Feature) -> dict:
@@ -1,7 +1,6 @@
1
1
  """Module containing the GMLAdapter for reading from and writing to gml."""
2
2
 
3
3
  import logging
4
- from typing import Literal
5
4
  from uuid import uuid4
6
5
 
7
6
  from lxml import etree
@@ -19,7 +18,6 @@ class GMLAdapter:
19
18
 
20
19
  def _to_etree(
21
20
  self,
22
- appschema: Literal["xplan", "xtrasse", "plu"] = "xplan",
23
21
  **kwargs,
24
22
  ) -> etree._Element:
25
23
  """Converts XPlan and INSPIRE PLU object to lxml etree Element."""
@@ -62,7 +60,7 @@ class GMLAdapter:
62
60
  "FORMAT=GML32",
63
61
  f"GMLID=GML_{uuid4()}",
64
62
  "SRSNAME_FORMAT=OGC_URL"
65
- if appschema == "plu"
63
+ if self.get_appschema() == "plu"
66
64
  else "GML3_LONGSRS=NO",
67
65
  "NAMESPACE_DECL=YES",
68
66
  ]
@@ -125,11 +123,11 @@ class GMLAdapter:
125
123
  if isinstance(model_value, list):
126
124
  value_item = model_value[index]
127
125
  etree.SubElement(feature, gml_name).append(
128
- value_item._to_etree(appschema)
126
+ value_item._to_etree()
129
127
  )
130
128
  else:
131
129
  etree.SubElement(feature, gml_name).append(
132
- model_value._to_etree(appschema)
130
+ model_value._to_etree()
133
131
  )
134
132
 
135
133
  ns = self.namespace_uri.replace("base/4.0", "base/3.3")